// Hovermate Game Events SDK client for C++17 on Windows: header-only, WinHTTP, one worker thread. // Protocol v1: https://docs.hovermate.gg/sdk/protocol/ // MIT License, Copyright (c) 2026 Paliverse Apps LLC. See LICENSE in the SDK. // // Usage (strings are UTF-8): // #include "hovermate_events.h" // hovermate::init("com.example.mygame", "My Game", "MyGame.exe"); // hovermate::hello({ "kill", "bossKill", hovermate::Offer::custom("parry", "Parry") }); // hovermate::event("kill", { "Double kill", { "headshot" }, "Act 2" }); // label, tags, context // hovermate::custom("parry", "Parry"); // hovermate::shutdown(); // before the mod is unloaded (FreeLibrary): never join threads inside DllMain // // Calls never block the game thread and never throw. Events go over HTTP from the worker thread (250 ms timeouts, a // bounded queue that drops the oldest event, one retry). When HTTP is unreachable they are appended to the inbox. // When Hovermate is not installed or its SDK is off, the client stays silent. Links winhttp.lib. #pragma once #ifndef _WIN32 #error "hovermate_events.h targets Windows (WinHTTP)." #endif #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _MSC_VER #pragma comment(lib, "winhttp.lib") #endif namespace hovermate { /// A type a hello offers: a standard type (a string converts implicitly) or a custom type with its toggle label. struct Offer { std::string type, customType, customLabel; Offer(const char* standard) : type(standard ? standard : "") {} Offer(std::string standard) : type(std::move(standard)) {} static Offer custom(std::string customType, std::string customLabel) { Offer offer{std::string()}; offer.customType = std::move(customType); offer.customLabel = std::move(customLabel); return offer; } }; /// Optional fields of one event. struct EventOptions { std::string label; // at most 80 characters: marker tooltip, clip title std::vector tags; // at most 8, each [a-z0-9_-]{1,24} std::string context; // map, level or area, at most 64 characters }; namespace detail { inline bool matches(std::string_view text, size_t minimum, size_t maximum, bool dots) { if (text.size() < minimum || text.size() > maximum) return false; for (char c : text) if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || (dots && c == '.'))) return false; return true; } /// Decodes one UTF-8 code point at `index` (invalid bytes become U+FFFD) and advances `index`. inline char32_t decode(std::string_view text, size_t& index) { unsigned char lead = static_cast(text[index++]); if (lead < 0x80) return lead; int extra = lead >= 0xF0 && lead <= 0xF4 ? 3 : lead >= 0xE0 ? 2 : lead >= 0xC2 && lead < 0xE0 ? 1 : -1; if (extra < 0 || index + extra > text.size()) return 0xFFFD; char32_t code = lead & (0x3F >> extra); for (int i = 0; i < extra; ++i) { unsigned char next = static_cast(text[index]); if ((next & 0xC0) != 0x80) return 0xFFFD; code = (code << 6) | (next & 0x3F); ++index; } if ((extra == 2 && code < 0x800) || (extra == 3 && (code < 0x10000 || code > 0x10FFFF)) || (code >= 0xD800 && code <= 0xDFFF)) return 0xFFFD; return code; } inline void encode(std::string& out, char32_t code) { if (code < 0x80) out += static_cast(code); else if (code < 0x800) { out += static_cast(0xC0 | (code >> 6)); out += static_cast(0x80 | (code & 0x3F)); } else if (code < 0x10000) { out += static_cast(0xE0 | (code >> 12)); out += static_cast(0x80 | ((code >> 6) & 0x3F)); out += static_cast(0x80 | (code & 0x3F)); } else { out += static_cast(0xF0 | (code >> 18)); out += static_cast(0x80 | ((code >> 12) & 0x3F)); out += static_cast(0x80 | ((code >> 6) & 0x3F)); out += static_cast(0x80 | (code & 0x3F)); } } /// Plain text: valid UTF-8, control characters as spaces, trimmed, at most `maximum` UTF-16 units; empty when blank. inline std::string clean(std::string_view text, size_t maximum) { std::vector codes; for (size_t index = 0; index < text.size();) { char32_t code = decode(text, index); codes.push_back(code < 0x20 || (code >= 0x7F && code <= 0x9F) ? U' ' : code); } size_t begin = 0, end = codes.size(); while (begin < end && codes[begin] == U' ') ++begin; while (end > begin && codes[end - 1] == U' ') --end; std::string out; size_t units = 0; for (size_t i = begin; i < end; ++i) { size_t width = codes[i] > 0xFFFF ? 2 : 1; if (units + width > maximum) break; units += width; encode(out, codes[i]); } while (!out.empty() && out.back() == ' ') out.pop_back(); return out; } inline void json_string(std::string& out, std::string_view value) { static const char* hex = "0123456789abcdef"; out += '"'; for (char c : value) { unsigned char u = static_cast(c); if (c == '"') out += "\\\""; else if (c == '\\') out += "\\\\"; else if (c == '\n') out += "\\n"; else if (c == '\r') out += "\\r"; else if (c == '\t') out += "\\t"; else if (u < 0x20 || u == 0x7F) { out += "\\u00"; out += hex[u >> 4]; out += hex[u & 15]; } else out += c; } out += '"'; } inline std::wstring widen(std::string_view text) { if (text.empty()) return std::wstring(); int size = MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), nullptr, 0); std::wstring out(static_cast(size > 0 ? size : 0), L'\0'); if (size > 0) MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), &out[0], size); return out; } /// The string members of a flat JSON object (other values are skipped). False when it is not an object. inline bool parse_flat_object(const std::string& text, std::vector>& out) { size_t i = 0; auto skip = [&] { while (i < text.size() && (text[i] == ' ' || text[i] == '\t' || text[i] == '\r' || text[i] == '\n')) ++i; }; auto read_string = [&](std::string& value) -> bool { if (i >= text.size() || text[i] != '"') return false; for (++i; i < text.size(); ++i) { char c = text[i]; if (c == '"') { ++i; return true; } if (c != '\\') { value += c; continue; } if (++i >= text.size()) return false; switch (text[i]) { case 'n': value += '\n'; break; case 'r': value += '\r'; break; case 't': value += '\t'; break; case 'b': value += '\b'; break; case 'f': value += '\f'; break; case 'u': { if (i + 4 >= text.size()) return false; char32_t code = static_cast(std::strtoul(text.substr(i + 1, 4).c_str(), nullptr, 16)); i += 4; if (code >= 0xD800 && code <= 0xDBFF && i + 6 < text.size() && text[i + 1] == '\\' && text[i + 2] == 'u') { char32_t low = static_cast(std::strtoul(text.substr(i + 3, 4).c_str(), nullptr, 16)); if (low >= 0xDC00 && low <= 0xDFFF) { code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00); i += 6; } } encode(value, code); break; } default: value += text[i]; break; } } return false; }; if (text.size() >= 3 && static_cast(text[0]) == 0xEF && static_cast(text[1]) == 0xBB && static_cast(text[2]) == 0xBF) i = 3; skip(); if (i >= text.size() || text[i] != '{') return false; ++i; while (true) { skip(); if (i < text.size() && text[i] == '}') return true; std::string key, value; if (!read_string(key)) return false; skip(); if (i >= text.size() || text[i] != ':') return false; ++i; skip(); if (i < text.size() && text[i] == '"') { if (!read_string(value)) return false; out.emplace_back(key, value); } else { int depth = 0; for (; i < text.size(); ++i) { char c = text[i]; if (c == '"') { std::string ignored; if (!read_string(ignored)) return false; --i; continue; } if (c == '{' || c == '[') ++depth; else if ((c == '}' || c == ']') && depth > 0) { if (--depth == 0) { ++i; break; } } else if ((c == ',' || c == '}') && depth == 0) break; } } skip(); if (i < text.size() && text[i] == ',') { ++i; continue; } return i < text.size() && text[i] == '}'; } } struct Connection { std::wstring host, path, token, inbox; INTERNET_PORT port = 0; }; class Client { public: ~Client() { stop(0); } void set_log(std::function log) { std::lock_guard lock(gate_); log_ = std::move(log); } void init(std::string_view gameId, std::string_view gameName, std::string_view exe) { if (!matches(gameId, 3, 64, true)) { report("game id must match [a-z0-9._-]{3,64}: the client stays off."); return; } std::string name = clean(gameName, 64); if (name.empty()) { report("game name is required: the client stays off."); return; } std::string file = clean(exe, 128); size_t slash = file.find_last_of("\\/"); if (slash != std::string::npos) file = file.substr(slash + 1); std::string game = "{\"id\":"; json_string(game, gameId); game += ",\"name\":"; json_string(game, name); if (!file.empty()) { game += ",\"exe\":"; json_string(game, file); } game += '}'; std::lock_guard lock(gate_); gameId_ = std::string(gameId); game_ = game; stopping_ = false; if (!worker_.joinable()) worker_ = std::thread([this] { run(); }); } void hello(const std::vector& offers, std::string_view log, const std::vector& files) { std::string extra = ",\"offers\":["; bool first = true; for (const Offer& offer : offers) { if (!offer.type.empty()) { if (!first) extra += ','; json_string(extra, offer.type); } else { std::string label = clean(offer.customLabel, 40); if (!matches(offer.customType, 1, 32, true) || label.empty()) { report("skipped an invalid custom offer."); continue; } if (!first) extra += ','; extra += "{\"customType\":"; json_string(extra, offer.customType); extra += ",\"customLabel\":"; json_string(extra, label); extra += '}'; } first = false; } extra += ']'; std::string path = clean(log, 260); if (!path.empty()) { extra += ",\"log\":"; json_string(extra, path); } std::vector kept; for (const std::string& file : files) { std::string value = clean(file, 260); if (!value.empty() && kept.size() < 16) kept.push_back(value); } if (!kept.empty()) { extra += ",\"files\":["; for (size_t i = 0; i < kept.size(); ++i) { if (i) extra += ','; json_string(extra, kept[i]); } extra += ']'; } enqueue("hello", extra); } void send(std::string_view type, std::string_view customType, std::string_view customLabel, const EventOptions& options) { if (type.empty() || type == "hello") { report("event type is required."); return; } std::string extra; if (type == "custom") { std::string toggle = clean(customLabel, 40); if (!matches(customType, 1, 32, true) || toggle.empty()) { report("custom events need customType [a-z0-9_.-]{1,32} and customLabel."); return; } extra += ",\"customType\":"; json_string(extra, customType); extra += ",\"customLabel\":"; json_string(extra, toggle); } std::string label = clean(options.label, 80); if (!label.empty()) { extra += ",\"label\":"; json_string(extra, label); } std::vector tags; for (const std::string& tag : options.tags) { bool seen = false; for (const std::string& kept : tags) seen = seen || kept == tag; if (tags.size() < 8 && !seen && matches(tag, 1, 24, false)) tags.push_back(tag); } if (!tags.empty()) { extra += ",\"tags\":["; for (size_t i = 0; i < tags.size(); ++i) { if (i) extra += ','; json_string(extra, tags[i]); } extra += ']'; } std::string context = clean(options.context, 64); if (!context.empty()) { extra += ",\"context\":"; json_string(extra, context); } enqueue(type, extra); } bool flush(unsigned timeoutMs) { std::unique_lock lock(gate_); return idle_.wait_for(lock, std::chrono::milliseconds(timeoutMs), [this] { return (queue_.empty() && !busy_) || !worker_.joinable(); }); } void stop(unsigned timeoutMs) { if (timeoutMs) flush(timeoutMs); std::thread worker; { std::lock_guard lock(gate_); stopping_ = true; queue_.clear(); worker = std::move(worker_); } wake_.notify_all(); if (worker.joinable()) { if (worker.get_id() == std::this_thread::get_id()) worker.detach(); else worker.join(); } if (session_) { WinHttpCloseHandle(session_); session_ = nullptr; } } private: static constexpr size_t Capacity = 256; static constexpr DWORD TimeoutMs = 250; std::mutex gate_; std::condition_variable wake_, idle_; std::deque queue_; std::function log_; std::string game_, gameId_; std::thread worker_; bool stopping_ = false, busy_ = false; // Worker-thread state. HINTERNET session_ = nullptr; Connection connection_; bool hasConnection_ = false, stale_ = true, reportedMissing_ = false; std::chrono::steady_clock::time_point nextLoad_{}, httpPausedUntil_{}; void report(const std::string& message) { std::function log; { std::lock_guard lock(gate_); log = log_; } if (!log) return; try { log("[Hovermate] " + message); } catch (...) {} } void enqueue(std::string_view type, const std::string& extra) { SYSTEMTIME now; GetSystemTime(&now); char stamp[32]; std::snprintf(stamp, sizeof stamp, "%04u-%02u-%02uT%02u:%02u:%02u.%03uZ", now.wYear, now.wMonth, now.wDay, now.wHour, now.wMinute, now.wSecond, now.wMilliseconds); { std::lock_guard lock(gate_); if (game_.empty() || !worker_.joinable()) return; std::string json = "{\"v\":1,\"game\":" + game_ + ",\"type\":"; json_string(json, type); json += extra; json += ",\"time\":\""; json += stamp; json += "\"}"; if (queue_.size() >= Capacity) queue_.pop_front(); queue_.push_back(std::move(json)); } wake_.notify_all(); } void run() { while (true) { std::string json, gameId; { std::unique_lock lock(gate_); busy_ = false; idle_.notify_all(); wake_.wait(lock, [this] { return stopping_ || !queue_.empty(); }); if (stopping_) { idle_.notify_all(); return; } json = std::move(queue_.front()); queue_.pop_front(); gameId = gameId_; busy_ = true; } try { deliver(json, gameId); } catch (...) { report("delivery failed."); } } } void deliver(const std::string& json, const std::string& gameId) { if (!current(false)) return; auto now = std::chrono::steady_clock::now(); if (now < httpPausedUntil_) { append_to_inbox(gameId, json); return; } int status = 0; std::string reply; for (int attempt = 0; attempt < 2; ++attempt) { status = post(json, reply); if (status == 202) return; if (attempt > 0) break; if (status == 401) { // The token rotates when the SDK is switched off and on: read the file again, retry once. if (!current(true)) return; continue; } if (status != -1) break; } if (status == -1) { httpPausedUntil_ = std::chrono::steady_clock::now() + std::chrono::seconds(5); stale_ = true; report("Hovermate's HTTP receiver is unreachable; using the inbox for a few seconds."); append_to_inbox(gameId, json); return; } report("Hovermate refused an event: " + std::to_string(status) + (reply.empty() ? "" : " " + reply)); } bool current(bool force) { auto now = std::chrono::steady_clock::now(); if (!force && hasConnection_ && !stale_) return true; if (!force && now < nextLoad_) return hasConnection_; nextLoad_ = now + std::chrono::seconds(5); stale_ = false; hasConnection_ = load(connection_); if (!hasConnection_ && !reportedMissing_) { report("connection.json not found: Hovermate is not running or its SDK is off."); reportedMissing_ = true; } if (hasConnection_) reportedMissing_ = false; return hasConnection_; } static std::wstring environment(const wchar_t* name) { DWORD size = GetEnvironmentVariableW(name, nullptr, 0); if (!size) return std::wstring(); std::wstring value(size, L'\0'); DWORD written = GetEnvironmentVariableW(name, &value[0], size); value.resize(written); return value; } static bool load(Connection& connection) { std::wstring path = environment(L"HOVERMATE_SDK_CONNECTION"); while (!path.empty() && (path.back() == L' ' || path.back() == L'"')) path.pop_back(); while (!path.empty() && (path.front() == L' ' || path.front() == L'"')) path.erase(0, 1); if (path.empty()) { std::wstring local = environment(L"LOCALAPPDATA"); if (local.empty()) return false; path = local + L"\\Hovermate\\Sdk\\connection.json"; } HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (file == INVALID_HANDLE_VALUE) return false; std::string text; char buffer[4096]; DWORD read = 0; while (text.size() < 64 * 1024 && ReadFile(file, buffer, sizeof buffer, &read, nullptr) && read > 0) text.append(buffer, read); CloseHandle(file); std::vector> values; if (!parse_flat_object(text, values)) return false; std::string url, token, inbox; for (auto& pair : values) { if (pair.first == "url") url = pair.second; else if (pair.first == "token") token = pair.second; else if (pair.first == "inbox") inbox = pair.second; } if (url.empty() || token.empty()) return false; std::wstring wide = widen(url); URL_COMPONENTS parts{}; parts.dwStructSize = sizeof parts; parts.dwHostNameLength = static_cast(-1); parts.dwUrlPathLength = static_cast(-1); parts.dwExtraInfoLength = static_cast(-1); if (!WinHttpCrackUrl(wide.c_str(), 0, 0, &parts) || parts.nScheme != INTERNET_SCHEME_HTTP) return false; connection.host.assign(parts.lpszHostName, parts.dwHostNameLength); connection.path.assign(parts.lpszUrlPath, parts.dwUrlPathLength); if (parts.lpszExtraInfo) connection.path.append(parts.lpszExtraInfo, parts.dwExtraInfoLength); connection.port = parts.nPort; connection.token = widen(token); connection.inbox = widen(inbox); return true; } /// One request in flight. WinHTTP's callbacks fill it; it lives until the request handle has closed. struct Exchange { HANDLE done = CreateEventW(nullptr, TRUE, FALSE, nullptr); HANDLE closed = CreateEventW(nullptr, TRUE, FALSE, nullptr); volatile LONG status = -1; std::string reply; char buffer[1024]; ~Exchange() { if (done) CloseHandle(done); if (closed) CloseHandle(closed); } }; static void CALLBACK on_status(HINTERNET handle, DWORD_PTR context, DWORD code, LPVOID, DWORD length) { Exchange* exchange = reinterpret_cast(context); if (!exchange) return; switch (code) { case WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE: if (!WinHttpReceiveResponse(handle, nullptr)) SetEvent(exchange->done); break; case WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE: { DWORD value = 0, size = sizeof value; if (WinHttpQueryHeaders(handle, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &value, &size, WINHTTP_NO_HEADER_INDEX)) InterlockedExchange(&exchange->status, static_cast(value)); if (!WinHttpReadData(handle, exchange->buffer, sizeof exchange->buffer, nullptr)) SetEvent(exchange->done); break; } case WINHTTP_CALLBACK_STATUS_READ_COMPLETE: if (length > 0 && exchange->reply.size() < 4096) { exchange->reply.append(exchange->buffer, length); if (!WinHttpReadData(handle, exchange->buffer, sizeof exchange->buffer, nullptr)) SetEvent(exchange->done); } else { SetEvent(exchange->done); } break; case WINHTTP_CALLBACK_STATUS_REQUEST_ERROR: SetEvent(exchange->done); break; case WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING: SetEvent(exchange->closed); break; default: break; } } /// One POST through asynchronous WinHTTP without a proxy, abandoned after 250 ms; -1 when Hovermate could not be /// reached in time. (Synchronous WinHTTP needs about 2 s to report a refused loopback connection, whatever its /// timeouts say.) int post(const std::string& body, std::string& reply) { reply.clear(); if (!session_) { session_ = WinHttpOpen(L"Hovermate-SDK-cpp/1", WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, WINHTTP_FLAG_ASYNC); if (!session_) return -1; WinHttpSetTimeouts(session_, TimeoutMs, TimeoutMs, TimeoutMs, TimeoutMs); WinHttpSetStatusCallback(session_, on_status, WINHTTP_CALLBACK_FLAG_ALL_COMPLETIONS | WINHTTP_CALLBACK_FLAG_HANDLES, 0); } HINTERNET connect = WinHttpConnect(session_, connection_.host.c_str(), connection_.port, 0); if (!connect) return -1; HINTERNET request = WinHttpOpenRequest(connect, L"POST", connection_.path.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, 0); if (!request) { WinHttpCloseHandle(connect); return -1; } Exchange* exchange = new Exchange(); DWORD_PTR context = reinterpret_cast(exchange); WinHttpSetOption(request, WINHTTP_OPTION_CONTEXT_VALUE, &context, sizeof context); std::wstring headers = L"Authorization: Bearer " + connection_.token + L"\r\nContent-Type: application/json\r\n"; DWORD length = static_cast(body.size()); if (!WinHttpSendRequest(request, headers.c_str(), static_cast(-1), const_cast(body.data()), length, length, context)) SetEvent(exchange->done); WaitForSingleObject(exchange->done, TimeoutMs); WinHttpCloseHandle(request); // cancels whatever is still running bool released = WaitForSingleObject(exchange->closed, 2000) == WAIT_OBJECT_0; WinHttpCloseHandle(connect); int status = static_cast(InterlockedCompareExchange(&exchange->status, 0, 0)); if (released) { reply = exchange->reply; delete exchange; // never freed while WinHTTP may still call back } return status; } void append_to_inbox(const std::string& gameId, const std::string& json) { if (connection_.inbox.empty()) return; DWORD attributes = GetFileAttributesW(connection_.inbox.c_str()); if (attributes == INVALID_FILE_ATTRIBUTES || !(attributes & FILE_ATTRIBUTE_DIRECTORY)) return; std::wstring path = connection_.inbox + L"\\" + widen(gameId) + L".jsonl"; HANDLE file = CreateFileW(path.c_str(), FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (file == INVALID_HANDLE_VALUE) { report("inbox write failed."); return; } std::string line = json + "\n"; DWORD written = 0; WriteFile(file, line.data(), static_cast(line.size()), &written, nullptr); CloseHandle(file); } }; inline Client& client() { static Client shared; return shared; } } // namespace detail /// Optional diagnostics (connection state, refused events), called from the worker thread. inline void set_log(std::function log) { try { detail::client().set_log(std::move(log)); } catch (...) {} } /// Starts the client. `gameId` matches [a-z0-9._-]{3,64} and stays stable across versions of the mod; `exe` is the /// game's executable file name (optional). inline void init(std::string_view gameId, std::string_view gameName, std::string_view exe = {}) { try { detail::client().init(gameId, gameName, exe); } catch (...) {} } /// Announces the types the game can send (optional, recommended), a log file with [HOVERMATE] lines and mod-folder /// event files. Hovermate asks the user before it reads a proposed path. inline void hello(const std::vector& offers, std::string_view log = {}, const std::vector& files = {}) { try { detail::client().hello(offers, log, files); } catch (...) {} } /// Sends a standard event type, for example "kill", "death" or "bossKill". inline void event(std::string_view type, const EventOptions& options = {}) { try { detail::client().send(type, {}, {}, options); } catch (...) {} } /// Sends a custom event: customType matches [a-z0-9_.-]{1,32}; customLabel (at most 40 characters) names its toggle. inline void custom(std::string_view customType, std::string_view customLabel, const EventOptions& options = {}) { try { detail::client().send("custom", customType, customLabel, options); } catch (...) {} } /// Waits until queued events are sent; false when the timeout passed first. inline bool flush(unsigned timeoutMs = 1000) { try { return detail::client().flush(timeoutMs); } catch (...) { return false; } } /// Sends what is queued (up to the timeout) and stops the worker thread. Call it before the mod is unloaded. inline void shutdown(unsigned timeoutMs = 1000) { try { detail::client().stop(timeoutMs); } catch (...) {} } } // namespace hovermate