-- Hovermate Game Events SDK client for Lua 5.1+ and LuaJIT (Cyber Engine Tweaks and other script sandboxes). -- Protocol v1: https://docs.hovermate.gg/sdk/protocol/ -- MIT License, Copyright (c) 2026 Paliverse Apps LLC. See LICENSE in the SDK. -- -- Sandboxes such as Cyber Engine Tweaks have no network and let a mod write only inside its own folder. This client -- therefore appends JSONL lines to hovermate-events.jsonl in the mod's folder first (protocol section 2.4). Tell your -- users to add that file in Hovermate: Clips > the game's card > Read events from a file. When that file cannot be -- written it falls back to Hovermate's inbox (only where absolute paths are allowed), then to [HOVERMATE] lines -- through a writer you pass, for example print. -- -- Usage: -- local hovermate = require("hovermate_events") -- hovermate.init("com.example.mygame", "My Game", "MyGame.exe") -- hovermate.hello({ "kill", "bossKill", hovermate.customOffer("parry", "Parry") }) -- hovermate.event("kill", { label = "Double kill", tags = { "headshot" }, context = "Act 2" }) -- hovermate.custom("parry", "Parry") -- -- init options (all optional): -- file = "hovermate-events.jsonl" -- path of the mod-folder file; relative paths start in the mod folder (CET) -- transport = "auto" -- "auto" (file, then inbox, then log), "file", "inbox" or "log" -- truncate = true -- empty the mod-folder file at init: a new session starts it over -- writer = function(line) end -- receives "[HOVERMATE] {...}" lines for the "log" transport -- log = function(message) end -- diagnostics -- Calls never raise into the game. local M = { version = 1 } local state = { game = nil, id = nil, transport = nil, path = nil, writer = nil, log = nil } local escapes = { ['"'] = '\\"', ['\\'] = '\\\\', ['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t' } local function quote(text) return '"' .. (text:gsub('[%c"\\]', function(c) return escapes[c] or string.format('\\u%04x', c:byte()) end)) .. '"' end local function report(message) if state.log then pcall(state.log, '[Hovermate] ' .. message) end end -- Cuts UTF-8 text to at most `maximum` UTF-16 units without splitting a character. local function clip(text, maximum) local units, index, size = 0, 1, #text while index <= size do local byte = text:byte(index) local length = (byte >= 0xF0 and 4) or (byte >= 0xE0 and 3) or (byte >= 0xC0 and 2) or 1 local width = length == 4 and 2 or 1 if units + width > maximum then return text:sub(1, index - 1) end units = units + width index = index + length end return text end -- Plain text: control characters become spaces, trimmed and cut; nil when blank. local function clean(text, maximum) if type(text) ~= 'string' then return nil end local value = text:gsub('%c', ' '):gsub('\194[\128-\159]', ' ') value = value:match('^%s*(.-)%s*$') value = clip(value, maximum):match('^(.-)%s*$') if value == '' then return nil end return value end local function matches(text, minimum, maximum, pattern) return type(text) == 'string' and #text >= minimum and #text <= maximum and text:match(pattern) ~= nil end local function list(values) local parts = {} for index = 1, #values do parts[index] = quote(values[index]) end return '[' .. table.concat(parts, ',') .. ']' end local function append(path, line) local ok, file = pcall(io.open, path, 'a') if not ok or not file then return false end local written = pcall(file.write, file, line, '\n') pcall(file.close, file) return written end -- Reads a JSON string value of `key` from a flat JSON object. local function json_field(text, key) local start = text:find('"' .. key .. '"%s*:%s*"') if not start then return nil end local index = text:find('"', start + #key + 2, true) + 1 local out = {} while index <= #text do local c = text:sub(index, index) if c == '"' then return table.concat(out) end if c == '\\' then index = index + 1 local e = text:sub(index, index) if e == 'u' then local code = tonumber(text:sub(index + 1, index + 4), 16) or 63 index = index + 4 if code < 0x80 then out[#out + 1] = string.char(code) elseif code < 0x800 then out[#out + 1] = string.char(0xC0 + math.floor(code / 64), 0x80 + code % 64) else out[#out + 1] = string.char(0xE0 + math.floor(code / 4096), 0x80 + math.floor(code / 64) % 64, 0x80 + code % 64) end else out[#out + 1] = ({ n = '\n', r = '\r', t = '\t', b = '\b', f = '\f' })[e] or e end else out[#out + 1] = c end index = index + 1 end return nil end -- The inbox file of this game, when connection.json is readable and the inbox can be written (never in CET). local function inbox_path(id) local ok, forced = pcall(os.getenv, 'HOVERMATE_SDK_CONNECTION') local path = ok and forced and forced:match('^%s*"?(.-)"?%s*$') or '' if path == '' then local found, local_data = pcall(os.getenv, 'LOCALAPPDATA') if not found or not local_data or local_data == '' then return nil end path = local_data .. '\\Hovermate\\Sdk\\connection.json' end local opened, file = pcall(io.open, path, 'r') if not opened or not file then return nil end local read, text = pcall(file.read, file, '*a') pcall(file.close, file) if not read or type(text) ~= 'string' then return nil end local inbox = json_field(text, 'inbox') if not inbox or inbox == '' then return nil end return inbox .. '\\' .. id .. '.jsonl' end -- Writes one JSON event through the chosen transport. local function deliver(json) if state.transport == 'file' or state.transport == 'inbox' then if append(state.path, json) then return end report('could not append to ' .. state.path) elseif state.transport == 'log' then local ok = pcall(state.writer, '[HOVERMATE] ' .. json) if not ok then report('the writer failed') end end end local function encode(kind, extra) if not state.game then return nil end return '{"v":1,"game":' .. state.game .. ',"type":' .. quote(kind) .. (extra or '') .. '}' end local function event_fields(options) options = type(options) == 'table' and options or {} local extra = '' local label = clean(options.label, 80) if label then extra = extra .. ',"label":' .. quote(label) end if type(options.tags) == 'table' then local kept, seen = {}, {} for _, tag in ipairs(options.tags) do if #kept < 8 and not seen[tag] and matches(tag, 1, 24, '^[a-z0-9_-]+$') then kept[#kept + 1] = tag seen[tag] = true end end if #kept > 0 then extra = extra .. ',"tags":' .. list(kept) end end local context = clean(options.context, 64) if context then extra = extra .. ',"context":' .. quote(context) end return extra end local function custom_fields(custom_type, custom_label) local toggle = clean(custom_label, 40) if not matches(custom_type, 1, 32, '^[a-z0-9_.-]+$') or not toggle then return nil end return ',"customType":' .. quote(custom_type) .. ',"customLabel":' .. quote(toggle) end --- A custom type for hello(offers). function M.customOffer(custom_type, custom_label) return { customType = custom_type, customLabel = custom_label } end --- Starts the client. gameId matches [a-z0-9._-]{3,64} and stays stable across versions of the mod. function M.init(game_id, game_name, exe, options) local ok, failure = pcall(function() options = type(options) == 'table' and options or {} state.log = type(options.log) == 'function' and options.log or nil state.writer = type(options.writer) == 'function' and options.writer or nil if not matches(game_id, 3, 64, '^[a-z0-9._-]+$') then report('game id must match [a-z0-9._-]{3,64}: the client stays off.') return end local name = clean(game_name, 64) if not name then report('game name is required: the client stays off.') return end local game = '{"id":' .. quote(game_id) .. ',"name":' .. quote(name) local file_name = clean(exe, 128) file_name = file_name and file_name:match('([^\\/]+)$') if file_name then game = game .. ',"exe":' .. quote(file_name) end state.game, state.id = game .. '}', game_id state.transport, state.path = nil, nil local wanted = options.transport or 'auto' local path = options.file or 'hovermate-events.jsonl' if wanted == 'auto' or wanted == 'file' then local opened, file = pcall(io.open, path, options.truncate == false and 'a' or 'w') if opened and file then pcall(file.close, file) state.transport, state.path = 'file', path return end report('cannot write ' .. path) end if wanted == 'auto' or wanted == 'inbox' then local inbox = inbox_path(game_id) local opened, file = false, nil if inbox then opened, file = pcall(io.open, inbox, 'a') end if opened and file then pcall(file.close, file) state.transport, state.path = 'inbox', inbox return end end if (wanted == 'auto' or wanted == 'log') and state.writer then state.transport = 'log' return end report('no transport is available: events are ignored.') end) if not ok then report('init failed: ' .. tostring(failure)) end end --- Where events go: "file", "inbox", "log" or nil, and the file path for the first two. function M.transport() return state.transport, state.path end --- Announces the types the game can send, a log file with [HOVERMATE] lines and mod-folder event files. function M.hello(offers, log, files) pcall(function() local extra = '' if type(offers) == 'table' then local parts = {} for _, offer in ipairs(offers) do if type(offer) == 'string' and offer ~= '' then parts[#parts + 1] = quote(offer) elseif type(offer) == 'table' and custom_fields(offer.customType, offer.customLabel) then parts[#parts + 1] = '{' .. custom_fields(offer.customType, offer.customLabel):sub(2) .. '}' else report('skipped an invalid offer.') end end extra = ',"offers":[' .. table.concat(parts, ',') .. ']' end local path = clean(log, 260) if path then extra = extra .. ',"log":' .. quote(path) end if type(files) == 'table' then local kept = {} for _, file in ipairs(files) do local value = clean(file, 260) if value and #kept < 16 then kept[#kept + 1] = value end end if #kept > 0 then extra = extra .. ',"files":' .. list(kept) end end local json = encode('hello', extra) if json then deliver(json) end end) end --- Sends a standard event type, for example "kill", "death" or "bossKill". function M.event(kind, options) pcall(function() if type(kind) ~= 'string' or kind == '' or kind == 'hello' or kind == 'custom' then report('a standard event type is required.') return end local json = encode(kind, event_fields(options)) if json then deliver(json) end end) end --- Sends a custom event: customType matches [a-z0-9_.-]{1,32}; customLabel (at most 40) names its toggle. function M.custom(custom_type, custom_label, options) pcall(function() local fields = custom_fields(custom_type, custom_label) if not fields then report('custom events need customType [a-z0-9_.-]{1,32} and customLabel.') return end local json = encode('custom', fields .. event_fields(options)) if json then deliver(json) end end) end --- The "[HOVERMATE] {...}" log line for a standard event, for games whose scripts can only print to a log. function M.logLine(kind, options) local ok, json = pcall(encode, kind, event_fields(options)) if ok and json then return '[HOVERMATE] ' .. json end return nil end --- Events are written at once; flush and shutdown exist for parity with the other clients. function M.flush() return true end function M.shutdown() state.game, state.transport, state.path = nil, nil, nil end return M