Last active
June 11, 2022 12:23
-
-
Save Earu/3f08e2b37cd72bfd0f7b330b26bee254 to your computer and use it in GitHub Desktop.
Generic asynchronous chatsounds lua parser
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module("chatsounds_parser", package.seeall) | |
function is_gmod_env() | |
return _G.VERSION and _G.VERSIONSTR and _G.BRANCH and _G.vector_origin -- these should be unique enough to determine that we're in gmod | |
end | |
lookup = {} | |
-- abstract away these methods are they are environement specific and we don't want to be constrained to gmod | |
function build_lookup() | |
error("build_lookup() is not implemented for this environment, please implement it") | |
-- should add keys to the lookup table such as lookup[key] = true | |
end | |
function run_task(task_name, fn) | |
error("run_task() is not implemented for this environment, please implement it") | |
end | |
function resolve_task(task_name) | |
error("resolve_task() is not implemented for this environment, please implement it") | |
end | |
function reject_task(task_name, err_str) | |
error("reject_task() is not implemented for this environment, please implement it") | |
end | |
function compile_lua_string(lua_str) | |
error("compile_lua_string() is not implemented for this environment, please implement it") | |
-- returns the function corresponding to the compiled lua string | |
end | |
-- implement the methods for gmod | |
if is_gmod_env() then | |
function build_lookup() | |
for _, cs_list in pairs(goluwa.env.chatsounds.custom) do | |
for _, cs_folder in pairs(cs_list.list) do | |
for cs_key, _ in pairs(cs_folder) do | |
lookup[cs_key] = true | |
end | |
end | |
end | |
end | |
function run_task(name, fn) | |
hook.Add("Think", name, fn) | |
end | |
function resolve_task(name) | |
hook.Remove("Think", name) | |
end | |
function reject_task(name, err_str) | |
hook.Remove("Think", name) | |
error(err_str) | |
end | |
local lua_str_env = { | |
PI = math.pi, | |
pi = math.pi, | |
rand = math.random, | |
random = math.random, | |
randomf = math.randomf, | |
abs = math.abs, | |
sgn = function (x) | |
if x < 0 then return -1 end | |
if x > 0 then return 1 end | |
return 0 | |
end, | |
acos = math.acos, | |
asin = math.asin, | |
atan = math.atan, | |
atan2 = math.atan2, | |
ceil = math.ceil, | |
cos = math.cos, | |
cosh = math.cosh, | |
deg = math.deg, | |
exp = math.exp, | |
floor = math.floor, | |
frexp = math.frexp, | |
ldexp = math.ldexp, | |
log = math.log, | |
log10 = math.log10, | |
max = math.max, | |
min = math.min, | |
rad = math.rad, | |
sin = math.sin, | |
sinc = function(x) | |
if x == 0 then return 1 end | |
return math.sin(x) / x | |
end, | |
sinh = math.sinh, | |
sqrt = math.sqrt, | |
tanh = math.tanh, | |
tan = math.tan, | |
clamp = math.clamp, | |
pow = math.pow, | |
clock = os.clock, | |
} | |
local blacklisted_syntax = { "repeat", "until", "function", "end", "\"", "\'", "%[=*%[", "%]=*%]", ":" } | |
function compile_lua_string(lua_str, identifier) | |
for _, syntax in pairs(blacklisted_syntax) do | |
if lua_str:find("[%p%s]" .. syntax) or lua_str:find(syntax .. "[%p%s]") then | |
return false, string.format("illegal characters used %q", syntax) | |
end | |
end | |
local env = table.Copy(lua_str_env) | |
local start_time = SysTime() | |
env.t = function() return SysTime() - start_time end | |
env.time = t | |
env.select = select | |
lua_str = "local input = select(1, ...) return " .. lua_str | |
local fn = CompileString(lua_str, identifier, false) | |
if isfunction(fn) then | |
setfenv(fn, env) | |
return fn | |
end | |
return nil | |
end | |
end | |
local function get_str_args(args, sep) | |
local sep_index = args:find(sep) | |
local str_args = {} | |
while sep_index do | |
table.insert(str_args, args:sub(1, sep_index - 1)) | |
args = args:sub(sep_index + 1) | |
sep_index = args:find(",") | |
end | |
table.insert(str_args, args) | |
return str_args | |
end | |
local modifier_lookup = {} | |
local modifiers = { | |
cutoff = { | |
name = "cutoff", | |
legacy_syntax = "--", | |
default_value = 0, | |
parse_args = function(args) | |
local cutoff = tonumber(args) | |
if not cutoff then return 0 end | |
return math.max(0, cutoff) | |
end, | |
}, | |
duration = { | |
name = "duration", | |
legacy_syntax = "=", | |
default_value = 0, | |
parse_args = function(args) | |
local duration = tonumber(args) | |
if not duration then return 0 end | |
return math.max(0, duration) | |
end, | |
}, | |
echo = { | |
name = "echo", | |
default_value = { 0, 0 }, | |
parse_args = function(args) | |
local str_args = get_str_args(args, ",") | |
local echo_delay = math.max(0, tonumber(str_args[1]) or 0) | |
local echo_duration = math.max(0, tonumber(str_args[2]) or 0) | |
return echo_delay, echo_duration | |
end, | |
}, | |
legacy_pitch = { | |
name = "legacy_pitch", | |
legacy_syntax = "%%", | |
only_legacy = true, | |
default_value = { 100, 100 }, | |
parse_args = function(args) | |
local str_args = get_str_args(args, "%.") | |
local pitch_start = math.min(math.max(1, tonumber(str_args[1]) or 100), 255) | |
local pitch_end = math.min(math.max(1, tonumber(str_args[2]) or 100), 255) | |
return pitch_start, pitch_end | |
end, | |
}, | |
legacy_volume = { | |
name = "legacy_volume", | |
legacy_syntax = "^^", | |
only_legacy = true, | |
default_value = { 100, 100 }, | |
parse_args = function(args) | |
local str_args = get_str_args(args, "%.") | |
local volume_start = math.max(1, tonumber(str_args[1]) or 100) | |
local volume_end = math.max(1, tonumber(str_args[2]) or 100) | |
return volume_start, volume_end | |
end, | |
}, | |
lfopitch = { | |
name = "lfo_pitch", | |
default_value = { 0, 0 }, | |
parse_args = function(args) | |
local str_args = get_str_args(args, ",") | |
local lfopitch_delay = math.max(0, tonumber(str_args[1]) or 0) | |
local lfopitch_duration = math.max(0, tonumber(str_args[2]) or 0) | |
return lfopitch_delay, lfopitch_duration | |
end, | |
}, | |
pitch = { | |
name = "pitch", | |
legacy_syntax = "%", | |
default_value = 100, | |
parse_args = function(args) | |
local pitch = tonumber(args) | |
if not pitch then return 100 end | |
return math.min(math.max(1, pitch), 255) | |
end, | |
}, | |
realm = { | |
name = "realm", | |
default_value = "", | |
parse_args = function(args) | |
return args | |
end, | |
}, | |
rep = { | |
name = "repeat", | |
legacy_syntax = "*", | |
default_value = 1, | |
parse_args = function(args) | |
local rep = tonumber(args) | |
if not rep then return 1 end | |
return math.max(1, rep) | |
end, | |
}, | |
["select"] = { | |
name = "select", | |
legacy_syntax = "#", | |
only_legacy = true, | |
default_value = 0, | |
parse_args = function(args) | |
local select_id = tonumber(args) | |
if not select_id then return 0 end | |
return math.max(0, select_id) | |
end, | |
}, | |
skip = { | |
name = "skip", | |
legacy_syntax = "++", | |
default_value = 0, | |
parse_args = function(args) | |
local skip = tonumber(args) | |
if not skip then return 0 end | |
return math.max(0, skip) | |
end, | |
}, | |
volume = { | |
name = "volume", | |
legacy_syntax = "^", | |
default_value = 1, | |
parse_args = function(args) | |
local volume = tonumber(args) | |
if volume then return math.abs(volume) end | |
return 1 | |
end, | |
legacy_parse_args = function(args) | |
local volume = tonumber(args) | |
if volume then return math.abs(volume / 100) end | |
return 1 | |
end, | |
}, | |
} | |
for modifier_name, modifier in pairs(modifiers) do | |
if not modifier.only_legacy then | |
modifier_lookup[modifier_name] = modifier | |
end | |
if modifier.legacy_syntax then | |
modifier_lookup[modifier.legacy_syntax] = { | |
default_value = modifier.legacy_default_value or modifier.default_value, | |
parse_args = modifier.legacy_parse_args or modifier.parse_args, | |
name = modifier.name, | |
} | |
end | |
end | |
local function try_yield(i) | |
if not coroutine.running() then return end | |
if i % 10 == 0 then coroutine.yield() end | |
end | |
local function parse_sounds(ctx) | |
if #ctx.current_str == 0 then return end | |
local cur_scope = ctx.scopes[#ctx.scopes] | |
if lookup[ctx.current_str] then | |
cur_scope.sounds = cur_scope.sounds or {} | |
table.insert(cur_scope.sounds, { text = ctx.current_str, modifiers = {}, type = "sound" }) | |
else | |
local start_index = 1 | |
while start_index < #ctx.current_str do | |
local matched = false | |
local last_space_index = -1 | |
for i = 0, #ctx.current_str do | |
try_yield(i) | |
local index = #ctx.current_str - i | |
if index < start_index then break end -- cant go lower than start index | |
-- we only want to match with words so account for space chars | |
if ctx.current_str[index] == " " or index == start_index then | |
index = index == start_index and #ctx.current_str + 1 or index -- small hack for end of string | |
last_space_index = index | |
local str_chunk = ctx.current_str:sub(start_index, index - 1) -- this would need trimming to account for extra spaces etc | |
if lookup[str_chunk] then | |
cur_scope.sounds = cur_scope.sounds or {} | |
table.insert(cur_scope.sounds, { text = str_chunk, modifiers = {}, type = "sound" }) | |
start_index = index + 1 | |
matched = true | |
break | |
end | |
end | |
end | |
if not matched then | |
-- that means there was only one word and it wasnt a sound | |
if last_space_index == -1 then | |
break -- no more words, break out of this loop | |
else | |
start_index = last_space_index + 1 | |
end | |
end | |
end | |
end | |
-- assign the modifiers to the last sound parsed, if any | |
if cur_scope.sounds then | |
cur_scope.sounds[#cur_scope.sounds].modifiers = ctx.modifiers | |
end | |
-- reset the current string and modifiers | |
ctx.current_str = "" | |
ctx.last_current_str_space_index = -1 | |
ctx.modifiers = {} | |
end | |
local scope_handlers = { | |
["("] = function(raw_str, index, ctx) | |
if ctx.in_lua_expression then return end | |
parse_sounds(ctx) | |
local cur_scope = table.remove(ctx.scopes, #ctx.scopes) | |
cur_scope.start_index = index | |
end, | |
[")"] = function(raw_str, index, ctx) | |
if ctx.in_lua_expression then return end | |
parse_sounds(ctx) -- will parse sounds and assign modifiers to said sounds if any | |
local parent_scope = ctx.scopes[#ctx.scopes] | |
local new_scope = { | |
children = {}, | |
parent = parent_scope, | |
start_index = -1, | |
end_index = index, | |
type = "group", | |
} | |
if #ctx.modifiers > 0 then | |
-- if there are modifiers, assign them to the scope | |
-- this needs to be flattened into an array later down the line if this scope becomes a modifier itself | |
new_scope.modifiers = ctx.modifiers | |
ctx.modifiers = {} | |
end | |
table.insert(parent_scope.children, 1, new_scope) | |
table.insert(ctx.scopes, new_scope) | |
end, | |
[":"] = function(raw_str, index, ctx) | |
if ctx.in_lua_expression then return end | |
local modifier = { type = "modifier" } | |
local modifier_name = ctx.current_str:lower() | |
local cur_scope = ctx.scopes[#ctx.scopes] | |
if #cur_scope.children > 0 then | |
local last_scope_child = cur_scope.children[1] | |
if modifier_lookup[modifier_name] then | |
last_scope_child.type = "modifier_expression" -- mark the scope as a modifier | |
if last_scope_child.modifiers then | |
for _, previous_modifier in ipairs(last_scope_child.modifiers) do | |
table.insert(ctx.modifiers, previous_modifier) | |
end | |
end | |
modifier.name = modifier_name | |
modifier.value = last_scope_child.expression_fn | |
and last_scope_child.expression_fn -- if there was a lua expression in the scope, use that | |
or modifier_lookup[modifier_name].parse_args(raw_str:sub(last_scope_child.start_index + 1, last_scope_child.end_index - 1)) | |
modifier.scope = last_scope_child | |
end | |
else | |
if modifier_lookup[modifier_name] then | |
modifier.name = modifier_name | |
modifier.value = modifier_lookup[modifier_name].default_value | |
end | |
end | |
table.insert(ctx.modifiers, 1, modifier) | |
ctx.current_str = "" | |
ctx.last_current_str_space_index = -1 | |
end, | |
["["] = function(raw_str, index, ctx) | |
ctx.in_lua_expression = false | |
local lua_str = raw_str:sub(index + 1, lua_string_end_index) | |
local cur_scope = ctx.scopes[#ctx.scopes] | |
local fn = compile_lua_string(lua_str, "chatsounds_parser_lua_string") | |
cur_scope.expression_fn = fn or function() end | |
end, | |
["]"] = function(raw_str, index, ctx) | |
ctx.in_lua_expression = true | |
ctx.lua_string_end_index = index - 1 | |
end, | |
} | |
local function parse_legacy_modifiers(ctx, char) | |
-- legacy modifiers are 2 chars max, so what we can do is check the current char and the previous | |
-- to match against the lookup table | |
local found_modifier | |
local modifier_start_index = 0 | |
if modifier_lookup[char] then | |
found_modifier = modifier_lookup[char] | |
modifier_start_index = 0 | |
elseif modifier_lookup[char .. ctx.current_str[1]] then | |
found_modifier = modifier_lookup[char .. ctx.current_str[1]] | |
modifier_start_index = 1 | |
end | |
if found_modifier then | |
local modifier = { type = "modifier", name = found_modifier.name } | |
local args_end_index = nil | |
if ctx.last_current_str_space_index ~= -1 then | |
args_end_index = ctx.last_current_str_space_index | |
end | |
modifier.value = found_modifier.parse_args(ctx.current_str:sub(modifier_start_index + 1, args_end_index)) | |
table.insert(ctx.modifiers, 1, modifier) | |
ctx.current_str = args_end_index and ctx.current_str:sub(ctx.last_current_str_space_index + 1) or "" | |
ctx.last_current_str_space_index = -1 | |
return true | |
end | |
return false | |
end | |
local function parse_str(raw_str) | |
local global_scope = { -- global parent scope for the string | |
children = {}, | |
parent = nil, | |
start_index = 1, | |
end_index = #raw_str, | |
type = "group" | |
} | |
local ctx = { | |
scopes = { global_scope }, | |
in_lua_expression = false, | |
lua_string_end_index = -1, | |
modifiers = {}, | |
current_str = "", | |
last_current_str_space_index = -1, | |
} | |
for i = 0, #raw_str do | |
try_yield(i) | |
local index = #raw_str - i | |
local char = raw_str[index] | |
if scope_handlers[char] then | |
scope_handlers[char](raw_str, index, ctx) | |
else | |
local standard_iteration = true | |
if i % 2 == 0 and parse_legacy_modifiers(ctx, char) then | |
-- check every even index so that we match pairs of chars, ideal for legacy modifiers that are 2 chars max in length and overlap | |
standard_iteration = false | |
end | |
if standard_iteration then | |
ctx.current_str = char .. ctx.current_str | |
if char == " " then | |
ctx.last_current_str_space_index = index | |
end | |
end | |
end | |
end | |
parse_sounds(ctx) | |
return coroutine.yield(global_scope) | |
end | |
local parse_id = 0 | |
function parse_async(raw_str, on_completed) | |
local co = coroutine.create(function() parse_str(raw_str:lower()) end) | |
local task_name = ("chatsounds_parser_[%d]"):format(parse_id) | |
parse_id = parse_id + 1 | |
run_task(task_name, function() | |
local status, result = coroutine.resume(co) | |
if not status then | |
reject_task(task_name, result) | |
return | |
end | |
if coroutine.status(co) == "dead" or istable(result) then | |
resolve_task(task_name) | |
on_completed(result or {}) | |
end | |
end) | |
end | |
function parse(raw_str) | |
local co = coroutine.create(function() parse_str(raw_str:lower()) end) | |
while coroutine.status(co) ~= "dead" do | |
local status, result = coroutine.resume(co) | |
if not status then error(result) end | |
if result then return result end | |
end | |
return {} | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment