Skip to content

Instantly share code, notes, and snippets.

@Earu
Last active June 11, 2022 12:23
Show Gist options
  • Save Earu/3f08e2b37cd72bfd0f7b330b26bee254 to your computer and use it in GitHub Desktop.
Save Earu/3f08e2b37cd72bfd0f7b330b26bee254 to your computer and use it in GitHub Desktop.
Generic asynchronous chatsounds lua parser
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