-- Minimal deterministic JSON encoder/decoder for CombatLog boundaries. local json = {} local MAX_DEPTH = 64 local empty_object_mt = {} local null_mt = {__tostring=function() return 'json.null' end} json.null = setmetatable({}, null_mt) local UTF8_REPLACEMENT = string.char(0xef, 0xbf, 0xbd) function json.object(value) value = value or {} if type(value) ~= 'table' then error('json.object expects table') end return setmetatable(value, empty_object_mt) end local function continuation(byte) return byte and byte >= 0x80 and byte <= 0xbf end local function valid_utf8_width(s, i) local a, b, c, d = s:byte(i, i + 3) if not a then return nil end if a <= 0x7f then return 1 end if a >= 0xc2 and a <= 0xdf and continuation(b) then return 2 end if a == 0xe0 and b and b >= 0xa0 and b <= 0xbf and continuation(c) then return 3 end if ((a >= 0xe1 and a <= 0xec) or (a >= 0xee and a <= 0xef)) and continuation(b) and continuation(c) then return 3 end if a == 0xed and b and b >= 0x80 and b <= 0x9f and continuation(c) then return 3 end if a == 0xf0 and b and b >= 0x90 and b <= 0xbf and continuation(c) and continuation(d) then return 4 end if a >= 0xf1 and a <= 0xf3 and continuation(b) and continuation(c) and continuation(d) then return 4 end if a == 0xf4 and b and b >= 0x80 and b <= 0x8f and continuation(c) and continuation(d) then return 4 end return nil end local function sanitize_utf8(s) local out, i = {}, 1 while i <= #s do local width = valid_utf8_width(s, i) if width then out[#out + 1] = s:sub(i, i + width - 1) i = i + width else out[#out + 1] = UTF8_REPLACEMENT i = i + 1 end end return table.concat(out) end local function escape_str(s) s = tostring(s) s = sanitize_utf8(s) s = s:gsub('\\', '\\\\') s = s:gsub('"', '\\"') s = s:gsub('[%z\1-\31]', function(c) local escapes = {['\b'] = '\\b', ['\f'] = '\\f', ['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t'} return escapes[c] or string.format('\\u%04x', string.byte(c)) end) return '"' .. s .. '"' end local function is_array(t) local n = 0 for k, _ in pairs(t) do if type(k) ~= 'number' then return false end if k > n then n = k end end for i = 1, n do if t[i] == nil then return false end end return n > 0 or (next(t) == nil) end local function encode_value(val, depth, seen) if depth > MAX_DEPTH then error('json encode maximum depth exceeded') end if val == json.null then return 'null' end local t = type(val) if val == nil then return 'null' elseif t == 'boolean' then return val and 'true' or 'false' elseif t == 'number' then if val ~= val or val == math.huge or val == -math.huge then return 'null' end return tostring(val) elseif t == 'string' then return escape_str(val) elseif t == 'table' then if seen[val] then error('json encode cycle detected') end seen[val] = true local encoded if getmetatable(val) ~= empty_object_mt and is_array(val) then local parts = {} for i = 1, #val do parts[#parts + 1] = encode_value(val[i], depth + 1, seen) end encoded = '[' .. table.concat(parts, ',') .. ']' else local parts = {} for k, v in pairs(val) do if type(k) == 'string' then parts[#parts + 1] = escape_str(k) .. ':' .. encode_value(v, depth + 1, seen) end end encoded = '{' .. table.concat(parts, ',') .. '}' end seen[val] = nil return encoded end return 'null' end function json.encode(val) return encode_value(val, 0, {}) end -- Recursive descent decoder for the JSON subset we emit. local function decode_error(msg, i, str) error(msg .. ' at ' .. tostring(i) .. ' near ' .. tostring(str and str:sub(i, i + 16))) end local function skip_ws(str, i) local _, j = str:find('^[ \t\n\r]*', i) return (j or i - 1) + 1 end local parse_value local function utf8_codepoint(code) if code <= 0x7f then return string.char(code) end if code <= 0x7ff then return string.char(0xc0 + math.floor(code / 64), 0x80 + code % 64) end if code <= 0xffff then return string.char(0xe0 + math.floor(code / 4096), 0x80 + math.floor(code / 64) % 64, 0x80 + code % 64) end return string.char(0xf0 + math.floor(code / 262144), 0x80 + math.floor(code / 4096) % 64, 0x80 + math.floor(code / 64) % 64, 0x80 + code % 64) end local function parse_string(str, i) if str:sub(i, i) ~= '"' then decode_error('expected string', i, str) end i = i + 1 local out = {} while i <= #str do local c = str:sub(i, i) if c == '"' then return table.concat(out), i + 1 elseif c == '\\' then local n = str:sub(i + 1, i + 1) local map = { ['"'] = '"', ['\\'] = '\\', ['/'] = '/', b = '\b', f = '\f', n = '\n', r = '\r', t = '\t' } if map[n] then out[#out + 1] = map[n] i = i + 2 elseif n == 'u' then local hex = str:sub(i + 2, i + 5) local code = #hex == 4 and tonumber(hex, 16) or nil -- Surrogate pairs are intentionally rejected: boundary payloads -- contain BMP identifiers only and accepting half-pairs would -- produce invalid UTF-8. if not code or code >= 0xd800 and code <= 0xdfff then decode_error('bad unicode escape', i, str) end out[#out + 1] = utf8_codepoint(code) i = i + 6 else decode_error('bad escape', i, str) end else if string.byte(c) < 32 then decode_error('unescaped control character', i, str) end out[#out + 1] = c i = i + 1 end end decode_error('unterminated string', i, str) end local function parse_number(str, i) local tail = str:sub(i) local num = tail:match('^-?0%.%d+[eE][+-]?%d+') or tail:match('^-?[1-9]%d*%.%d+[eE][+-]?%d+') or tail:match('^-?0[eE][+-]?%d+') or tail:match('^-?[1-9]%d*[eE][+-]?%d+') or tail:match('^-?0%.%d+') or tail:match('^-?[1-9]%d*%.%d+') or tail:match('^-?0') or tail:match('^-?[1-9]%d*') local value = num and tonumber(num) or nil if not value or value ~= value or value == math.huge or value == -math.huge then decode_error('bad number', i, str) end return value, i + #num end local function parse_array(str, i, depth) if depth > MAX_DEPTH then decode_error('maximum depth exceeded', i, str) end i = i + 1 i = skip_ws(str, i) local arr = {} if str:sub(i, i) == ']' then return arr, i + 1 end while true do local v v, i = parse_value(str, i, depth) arr[#arr + 1] = v i = skip_ws(str, i) local c = str:sub(i, i) if c == ']' then return arr, i + 1 elseif c == ',' then i = skip_ws(str, i + 1) else decode_error('expected , or ]', i, str) end end end local function parse_object(str, i, depth) if depth > MAX_DEPTH then decode_error('maximum depth exceeded', i, str) end i = i + 1 i = skip_ws(str, i) local obj = json.object() if str:sub(i, i) == '}' then return obj, i + 1 end while true do local key key, i = parse_string(str, i) i = skip_ws(str, i) if str:sub(i, i) ~= ':' then decode_error('expected :', i, str) end i = skip_ws(str, i + 1) local val val, i = parse_value(str, i, depth) obj[key] = val i = skip_ws(str, i) local c = str:sub(i, i) if c == '}' then return obj, i + 1 elseif c == ',' then i = skip_ws(str, i + 1) else decode_error('expected , or }', i, str) end end end parse_value = function(str, i, depth) depth = depth or 0 i = skip_ws(str, i) local c = str:sub(i, i) if c == '"' then return parse_string(str, i) elseif c == '{' then return parse_object(str, i, depth + 1) elseif c == '[' then return parse_array(str, i, depth + 1) elseif c == 't' and str:sub(i, i + 3) == 'true' then return true, i + 4 elseif c == 'f' and str:sub(i, i + 4) == 'false' then return false, i + 5 elseif c == 'n' and str:sub(i, i + 3) == 'null' then return json.null, i + 4 elseif c == '-' or c:match('%d') then return parse_number(str, i) end decode_error('unexpected token', i, str) end function json.is_null(value) return value == json.null end -- Convert explicit JSON nulls to absent Lua values for schemas whose existing -- domain model uses nil. Arrays containing null are compacted deliberately; -- callers should use the preserving decode result when positions matter. function json.without_nulls(value, seen) if value == json.null then return nil end if type(value) ~= 'table' then return value end seen = seen or {} if seen[value] then return seen[value] end local out = getmetatable(value) == empty_object_mt and json.object() or {} seen[value] = out if getmetatable(value) == empty_object_mt then for key, item in pairs(value) do local converted = json.without_nulls(item, seen) if converted ~= nil then out[key] = converted end end else for _, item in ipairs(value) do local converted = json.without_nulls(item, seen) if converted ~= nil then out[#out + 1] = converted end end end return out end function json.decode(str) if type(str) ~= 'string' then error('json.decode expects string') end local val, i = parse_value(str, 1) i = skip_ws(str, i) if i <= #str then decode_error('trailing garbage', i, str) end return val end return json