From dd0bb87eb274894fa867a74f0089eab5c664500c Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 10 Jun 2026 06:31:17 +0100 Subject: [PATCH 1/7] feat(replay): dual-stream deterministic action log (MP.RLOG) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a fully-recreatable action logging system. Every state-affecting player action emits two aligned streams from one record() call: - Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with no card names, so it is indiscriminate and replays across mods. Written to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod folder, so version hotswaps don't disturb it). - Human stream: mirrored into the carbon file; the player-facing Lovely log is left byte-for-byte unchanged so the website parser is undisturbed. Both streams are hashed at game end (joker_hash) and submitted to the server via the new submitLogHashes action for cheap tamper detection. Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard, pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid). Instrumentation extends existing overrides; reorder uses a Preview- independent debounced CardArea:update detector. This is the logging system only; the replay runner and server-side hash storage are follow-ups. Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua (both pass on Lua 5.4). 🤖 Commit message generated by Claude Code (https://claude.com/claude-code) --- compatibility/Preview/CorePreview.lua | 10 +- lib/card_utils.lua | 78 ++++++++++ lib/replay_log.lua | 200 ++++++++++++++++++++++++++ networking/action_handlers.lua | 54 ++++++- overrides/game.lua | 122 +++++++++++++--- tests/readme.md | 27 ++++ tests/test_rlog_checksum.lua | 71 +++++++++ tests/test_rlog_roundtrip.lua | 143 ++++++++++++++++++ ui/game/functions.lua | 12 ++ ui/game/timer.lua | 5 + 10 files changed, 703 insertions(+), 19 deletions(-) create mode 100644 lib/replay_log.lua create mode 100644 tests/test_rlog_checksum.lua create mode 100644 tests/test_rlog_roundtrip.lua diff --git a/compatibility/Preview/CorePreview.lua b/compatibility/Preview/CorePreview.lua index ee84cffee..0a2fa08e6 100644 --- a/compatibility/Preview/CorePreview.lua +++ b/compatibility/Preview/CorePreview.lua @@ -130,9 +130,17 @@ end local orig_discard = G.FUNCS.discard_cards_from_highlighted function G.FUNCS.discard_cards_from_highlighted(e, is_hook_blind) + -- Carbon: capture which hand slots are discarded BEFORE orig_discard consumes + -- them. is_hook_blind means a programmatic discard (blind effect), not a click. + local discarded = (not is_hook_blind) and MP.UTILS.highlighted_hand_indices() or nil orig_discard(e, is_hook_blind) - if not is_hook_blind then FN.PRE.stop_current_coroutine() end + if not is_hook_blind then + if discarded and #discarded > 0 then + MP.RLOG.record("discard", { discarded }, "action:discard,cards:" .. table.concat(discarded, ".")) + end + FN.PRE.stop_current_coroutine() + end end -- USER INTERFACE ADVICE: diff --git a/lib/card_utils.lua b/lib/card_utils.lua index dbb3fe3b0..9c2657be5 100644 --- a/lib/card_utils.lua +++ b/lib/card_utils.lua @@ -45,6 +45,84 @@ function MP.UTILS.joker_to_string(card) return joker_string end +-- Stable area enum for the carbon replay stream. The int identifies WHICH +-- CardArea a positional index refers to, independent of card identity/name. +MP.UTILS.AREA = { + shop_jokers = 1, + shop_booster = 2, + shop_vouchers = 3, + jokers = 4, + consumeables = 5, + hand = 6, + pack_cards = 7, +} + +-- Map a live CardArea object to its stable AREA enum int (or nil if unknown). +function MP.UTILS.area_enum(area) + if not area or not G then return nil end + local lookup = { + [G.shop_jokers] = MP.UTILS.AREA.shop_jokers, + [G.shop_booster] = MP.UTILS.AREA.shop_booster, + [G.shop_vouchers] = MP.UTILS.AREA.shop_vouchers, + [G.jokers] = MP.UTILS.AREA.jokers, + [G.consumeables] = MP.UTILS.AREA.consumeables, + [G.hand] = MP.UTILS.AREA.hand, + [G.pack_cards] = MP.UTILS.AREA.pack_cards, + } + return lookup[area] +end + +-- 1-based index of a card within its CardArea's card list. `area` defaults to +-- card.area. Returns nil if the card is not found. This positional index is the +-- deterministic reference used by the carbon stream (never card.sort_id, which +-- is a per-run counter that won't match across a re-simulation). +function MP.UTILS.index_in_area(card, area) + area = area or (card and card.area) + if not card or not area or not area.cards then return nil end + for i = 1, #area.cards do + if area.cards[i] == card then return i end + end + return nil +end + +-- 1-based G.hand indices of the currently highlighted cards, ascending. Shared +-- by play/discard/consumable-target instrumentation so every hand reference in +-- the carbon stream is a deterministic positional index list. +function MP.UTILS.highlighted_hand_indices() + local out = {} + if not (G and G.hand and G.hand.highlighted) then return out end + for _, c in ipairs(G.hand.highlighted) do + local i = MP.UTILS.index_in_area(c, G.hand) + if i then out[#out + 1] = i end + end + table.sort(out) + return out +end + +-- Given the previous order (a list of card sort_ids) and the current cards, +-- return the new order expressed as a list of the cards' PREVIOUS 1-based +-- indices -- i.e. the permutation a replay applies to reproduce the reorder. +-- Returns nil if it is not a pure reorder (the card set changed) or if nothing +-- moved. Referencing previous indices (not sort_id) keeps the carbon stream +-- positional and replayable. +function MP.UTILS.reorder_permutation(old_ids, cards) + if not old_ids or not cards or #cards == 0 or #old_ids ~= #cards then return nil end + local pos = {} + for i = 1, #old_ids do + pos[old_ids[i]] = i + end + local perm = {} + local changed = false + for j = 1, #cards do + local oi = pos[cards[j].sort_id] + if not oi then return nil end -- a card is new/removed: not a pure reorder + perm[j] = oi + if oi ~= j then changed = true end + end + if not changed then return nil end + return perm +end + -- ??? seems to be dead code function MP.UTILS.get_joker(key) if not G.jokers or not G.jokers.cards then return nil end diff --git a/lib/replay_log.lua b/lib/replay_log.lua new file mode 100644 index 000000000..868af8e05 --- /dev/null +++ b/lib/replay_log.lua @@ -0,0 +1,200 @@ +-- Replay Log (MP.RLOG): dual-stream, deterministic, fully-recreatable action log. +-- +-- Two streams are emitted from the SAME instrumentation points so they stay +-- event-for-event aligned (shared monotonic sequence number): +-- +-- 1. Carbon-copy (replay) stream -- positional, no names. "A buy 1 1" +-- means "buy shop-jokers slot 1". Indiscriminate, so modded content is +-- just "slot N" and replays across mods for free. This is the only truly +-- replayable stream. The future replay runner re-runs the game from the +-- manifest seed and feeds these opcodes in order. +-- +-- 2. Human-readable stream -- the semantic mirror. "H action:..." keeps +-- the existing log payload format so the website parser stays compatible. +-- +-- BOTH streams live in a dedicated ".carbon" sidecar next to the Lovely log +-- (NOT in the mod folder, so version hotswaps don't disturb it). The .carbon +-- file is the self-contained dev/verification artifact: manifest header, the A +-- and H lines, then a trailer with a separate hash of each stream. +-- +-- The player-facing Lovely log is left 100% unchanged: the existing per-action +-- sendTraceMessage calls keep emitting the human lines players read, and the new +-- carbon pipe never touches it. record() therefore does NOT write to the Lovely +-- log -- it only appends to the .carbon file. Pass record() the same human +-- payload those existing log lines use so the carbon H stream mirrors them. +-- +-- At end_run both streams are hashed and the hashes are sent to the server, so +-- tamper-checking later is a cheap hash comparison instead of a line-by-line +-- diff. NOTE: MP.UTILS.joker_hash (Adler-style) catches casual edits but is NOT +-- collision-resistant -- a motivated forger could edit and fix the hash. The +-- robust defenses (server sees the lines live, or full re-simulation) are future +-- work; this hash is the intended cheap first pass. + +local RLOG = {} +MP.RLOG = RLOG + +-- Required manifest keys; begin_run warns if any are missing. +RLOG.REQUIRED_MANIFEST_KEYS = { "seed", "ruleset", "gamemode", "deck", "stake" } + +RLOG._seq = 0 +RLOG._carbon_buffer = {} -- the "A " lines (positional stream), hashed at end +RLOG._human_buffer = {} -- the "H " lines (human stream), hashed at end +RLOG._pending = {} -- lines awaiting flush to the .carbon file +RLOG._carbon_path = nil +RLOG._run_active = false +RLOG._force_active = false -- test hook: bypass the lobby gate + +------------------------------------------------------------------------------- +-- Gate +------------------------------------------------------------------------------- + +-- Only real multiplayer games log. Ghost playback, practice, and the preview +-- simulation have no lobby code, so they never emit. +function RLOG.is_active() + if RLOG._force_active then return true end + if not (MP.LOBBY and MP.LOBBY.code) then return false end + if MP.GHOST and MP.GHOST.is_active and MP.GHOST.is_active() then return false end + return true +end + +------------------------------------------------------------------------------- +-- Internal helpers +------------------------------------------------------------------------------- + +-- Derive the carbon sidecar path from the Lovely log path by swapping the +-- trailing extension for ".carbon" (same folder, same base name). +local function derive_carbon_path() + local ok, lovely = pcall(require, "lovely") + if not ok or not lovely or not lovely.log_path then return nil end + -- Strip a trailing "." on the final path segment only, then append. + local base = lovely.log_path:gsub("%.[^%.\\/]*$", "") + return base .. ".carbon" +end + +-- Format an opcode's args into the positional arg string. +-- Each token is either a scalar -> "1" or a list -> dot-joined "1.3.5". +-- A bare scalar/string is treated as a single token. +local function fmt_args(args) + if args == nil then return "" end + if type(args) ~= "table" then return tostring(args) end + local parts = {} + for _, tok in ipairs(args) do + if type(tok) == "table" then + local sub = {} + for _, v in ipairs(tok) do + sub[#sub + 1] = tostring(v) + end + parts[#parts + 1] = table.concat(sub, ".") + else + parts[#parts + 1] = tostring(tok) + end + end + return table.concat(parts, " ") +end + +local function push_carbon(line) + RLOG._carbon_buffer[#RLOG._carbon_buffer + 1] = line + RLOG._pending[#RLOG._pending + 1] = line +end + +local function push_human(line) + RLOG._human_buffer[#RLOG._human_buffer + 1] = line + RLOG._pending[#RLOG._pending + 1] = line +end + +-- Structural lines (MANIFEST/END/CHK) go to the file but are not part of the +-- per-stream hash buffers. +local function push_raw(line) + RLOG._pending[#RLOG._pending + 1] = line +end + +------------------------------------------------------------------------------- +-- File writing +------------------------------------------------------------------------------- + +-- Append any buffered lines to the .carbon file. Append-mode so multiple games +-- in one Lovely session accumulate as sequential blocks. Called on begin_run, +-- once per round, and on end_run to bound data loss on crash. +function RLOG.flush() + if #RLOG._pending == 0 then return end + if not RLOG._carbon_path then return end + local f = io.open(RLOG._carbon_path, "a") + if not f then + sendWarnMessage("RLOG: could not open carbon file " .. tostring(RLOG._carbon_path), "MULTIPLAYER") + return + end + f:write(table.concat(RLOG._pending, "\n") .. "\n") + f:close() + RLOG._pending = {} +end + +------------------------------------------------------------------------------- +-- Public API +------------------------------------------------------------------------------- + +-- Record one state-affecting action. Writes the carbon (positional) line and, +-- when a human payload is provided, the mirrored human line -- both into the +-- .carbon file with the same sequence number. Never writes to the Lovely log. +-- opcode : string, e.g. "buy" +-- args : nil | scalar | list of tokens (scalar or sub-list); see fmt_args +-- human : nil | string payload in the existing "action:key,..." format +function RLOG.record(opcode, args, human) + if not RLOG.is_active() or not RLOG._run_active then return end + + RLOG._seq = RLOG._seq + 1 + local seq = RLOG._seq + + local argstr = fmt_args(args) + push_carbon("A " .. seq .. " " .. opcode .. (argstr ~= "" and (" " .. argstr) or "")) + + if human ~= nil and human ~= "" then push_human("H " .. seq .. " " .. human) end +end + +-- Start a new game's block. Resets counters/buffers, resolves the carbon path, +-- writes the manifest header, and flushes it immediately. +function RLOG.begin_run(manifest) + manifest = manifest or {} + + for _, key in ipairs(RLOG.REQUIRED_MANIFEST_KEYS) do + if manifest[key] == nil then + sendWarnMessage("RLOG: manifest missing required key '" .. key .. "'", "MULTIPLAYER") + end + end + + RLOG._seq = 0 + RLOG._carbon_buffer = {} + RLOG._human_buffer = {} + RLOG._pending = {} + RLOG._carbon_path = derive_carbon_path() + RLOG._manifest = manifest + RLOG._run_active = true + + local json = require("json") + push_raw("MANIFEST " .. json.encode(manifest)) + RLOG.flush() +end + +-- Close the current game's block: write the END line, hash each stream, write +-- the CHK trailer, flush, and submit both hashes to the server. +function RLOG.end_run(outcome) + if not RLOG._run_active then return end + + local json = require("json") + push_raw("END " .. json.encode(outcome or {})) + + local carbon_str = table.concat(RLOG._carbon_buffer, "\n") + local human_str = table.concat(RLOG._human_buffer, "\n") + local carbon_hash = MP.UTILS.joker_hash(carbon_str) + local human_hash = MP.UTILS.joker_hash(human_str) + local bytes = #carbon_str + #human_str + + push_raw(string.format("CHK v1 carbon=%s human=%s bytes=%d", carbon_hash, human_hash, bytes)) + RLOG.flush() + + if MP.ACTIONS and MP.ACTIONS.submit_log_hashes then + MP.ACTIONS.submit_log_hashes(carbon_hash, human_hash, RLOG._manifest and RLOG._manifest.seed) + end + + RLOG._run_active = false + return carbon_hash, human_hash +end diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index c84ef6eba..869f1cc57 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -271,6 +271,33 @@ local function action_start_game(p) if not MP.LOBBY.config.different_seeds and MP.LOBBY.config.custom_seed ~= "random" then seed = MP.LOBBY.config.custom_seed end + + -- Open a new replay-log block for this game with everything needed to + -- reconstruct it deterministically later. Uses the resolved seed. + MP.RLOG.begin_run({ + seed = seed, + stake = stake, + deck = MP.LOBBY.config.back, + sleeve = MP.LOBBY.config.sleeve, + challenge = MP.LOBBY.config.challenge, + ruleset = MP.LOBBY.config.ruleset, + gamemode = MP.LOBBY.config.gamemode, + modifier_layers = MP.LOBBY.config.modifier_layers, + lobby_config = MP.LOBBY.config, + the_order_enabled = MP.should_use_the_order(), + different_seeds = MP.LOBBY.config.different_seeds, + mod_version = SMODS.Mods["Multiplayer"] and SMODS.Mods["Multiplayer"].version, + mod_hash = MP.MOD_STRING, + smods_version = MP.SMODS_VERSION, + lovely_version = MP.REQUIRED_LOVELY_VERSION, + lobby_code = MP.LOBBY.code, + is_host = MP.LOBBY.is_host, + player = MP.LOBBY.username, + opponent = (MP.LOBBY.is_host and MP.LOBBY.guest and MP.LOBBY.guest.username) + or (MP.LOBBY.host and MP.LOBBY.host.username), + start_ts = os.date("%Y-%m-%dT%H:%M:%S%z"), + }) + G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake }) MP.LOBBY.ready_to_start = false end @@ -401,6 +428,7 @@ local function action_stop_game() MP.UI.update_connection_status() MP.reset_game_states() end + MP.RLOG.end_run({ result = "stop" }) MP.UTILS.emit_log_checksum() end @@ -445,6 +473,7 @@ local function action_win_game() MP.nemesis_deck_received = false MP.GAME.won = true MP.STATS.record_match(true) + MP.RLOG.end_run({ result = "win" }) MP.UTILS.log_mem_debug_messages() MP.UTILS.emit_log_checksum() win_game() @@ -458,6 +487,7 @@ local function action_lose_game() MP.STATS.record_match(false) G.STATE_COMPLETE = false G.STATE = G.STATES.GAME_OVER + MP.RLOG.end_run({ result = "loss" }) MP.UTILS.log_mem_debug_messages() MP.UTILS.emit_log_checksum() end @@ -527,6 +557,9 @@ end local function action_send_phantom(p) local key = p.key + -- Carbon: exogenous opponent effect. Keyed by content (not a board index), + -- logged in received order so a solo re-sim reproduces it faithfully. + MP.RLOG.record("net_phantom_add", key, "action:netPhantomAdd,key:" .. tostring(key)) local menu = G.OVERLAY_MENU -- we are spoofing a menu here, which disables duplicate protection G.OVERLAY_MENU = G.OVERLAY_MENU or true local new_card = create_card("Joker", MP.shared, false, nil, nil, nil, key) @@ -537,6 +570,7 @@ local function action_send_phantom(p) end local function action_remove_phantom(p) + MP.RLOG.record("net_phantom_remove", p.key, "action:netPhantomRemove,key:" .. tostring(p.key)) local card = MP.UTILS.get_phantom_joker(p.key) if card then card:remove_from_deck() @@ -610,10 +644,14 @@ local function action_version() MP.ACTIONS.version() end -local action_asteroid = action_asteroid +local action_asteroid_ref = action_asteroid or function() if MP.UI.show_asteroid_hand_level_up then MP.UI.show_asteroid_hand_level_up() end end +local function action_asteroid(p) + MP.RLOG.record("net_asteroid", nil, "action:netAsteroid") + return action_asteroid_ref(p) +end local function action_sold_joker() -- HACK: this action is being sent when any card is being sold, since Taxes is now reworked @@ -631,6 +669,7 @@ end local function action_eat_pizza(p) local discards = p.whole -- rename to "discards" when possible + MP.RLOG.record("net_pizza", discards, "action:netPizza,discards:" .. tostring(discards)) MP.GAME.pizza_discards = MP.GAME.pizza_discards + discards G.GAME.round_resets.discards = G.GAME.round_resets.discards + discards ease_discard(discards) @@ -641,6 +680,7 @@ local function action_spent_last_shop(p) end local function action_magnet() + MP.RLOG.record("net_magnet", nil, "action:netMagnet") local card = nil for _, v in pairs(G.jokers.cards) do if not card or v.sell_cost > card.sell_cost then card = v end @@ -1275,6 +1315,18 @@ function MP.ACTIONS.sync_client() }) end +-- End-of-game replay-log fingerprints. The server stores these (keyed by +-- lobby + seed + game) so a presented log can later be re-hashed and compared +-- without a line-by-line diff. See lib/replay_log.lua (MP.RLOG). +function MP.ACTIONS.submit_log_hashes(carbon, human, seed) + Client.send({ + action = "submitLogHashes", + carbon = carbon, + human = human, + seed = seed, + }) +end + function MP.ACTIONS.modded(modId, modAction, params, target) local msg = { action = "moddedAction", diff --git a/overrides/game.lua b/overrides/game.lua index 47fbe7456..36002b346 100644 --- a/overrides/game.lua +++ b/overrides/game.lua @@ -15,20 +15,25 @@ end local sell_card_ref = Card.sell_card function Card:sell_card() if self.ability and self.ability.name then - sendTraceMessage( - string.format("Client sent message: action:soldCard,card:%s", self.ability.name), - "MULTIPLAYER" - ) + local human = string.format("action:soldCard,card:%s", self.ability.name) + sendTraceMessage("Client sent message: " .. human, "MULTIPLAYER") + -- Carbon: positional sell by area + slot, captured before the card leaves + -- its area. Area distinguishes selling a joker (4) from a consumable (5). + local area = MP.UTILS.area_enum(self.area) + local idx = MP.UTILS.index_in_area(self) + if area and idx then MP.RLOG.record("sell", { area, idx }, human) end end return sell_card_ref(self) end local reroll_shop_ref = G.FUNCS.reroll_shop function G.FUNCS.reroll_shop(e) - sendTraceMessage( - string.format("Client sent message: action:rerollShop,cost:%s", G.GAME.current_round.reroll_cost), - "MULTIPLAYER" - ) + local human = string.format("action:rerollShop,cost:%s", G.GAME.current_round.reroll_cost) + sendTraceMessage("Client sent message: " .. human, "MULTIPLAYER") + + -- Carbon: reroll has no positional target; the shop contents it produces are + -- deterministic from the seed, so the bare opcode is enough to replay. + MP.RLOG.record("reroll", nil, human) -- Update reroll stats if in a multiplayer game if MP.LOBBY.code and MP.GAME.stats then @@ -43,21 +48,49 @@ local buy_from_shop_ref = G.FUNCS.buy_from_shop function G.FUNCS.buy_from_shop(e) local c1 = e.config.ref_table if c1 and c1:is(Card) then - sendTraceMessage( - string.format("Client sent message: action:boughtCardFromShop,card:%s,cost:%s", c1.ability.name, c1.cost), - "MULTIPLAYER" - ) + local human = string.format("action:boughtCardFromShop,card:%s,cost:%s", c1.ability.name, c1.cost) + sendTraceMessage("Client sent message: " .. human, "MULTIPLAYER") + -- Carbon: positional buy by shop area + slot, captured before the card + -- leaves the shop. Booster packs and vouchers get distinct opcodes since + -- they branch the game differently, but all reference an area + slot. + local area = MP.UTILS.area_enum(c1.area) + local idx = MP.UTILS.index_in_area(c1) + if area and idx then + local opcode = "buy" + local set = c1.ability and c1.ability.set + if set == "Booster" then + opcode = "open_pack" + elseif set == "Voucher" then + opcode = "voucher" + end + MP.RLOG.record(opcode, { area, idx }, human) + end end return buy_from_shop_ref(e) end local use_card_ref = G.FUNCS.use_card function G.FUNCS.use_card(e, mute, nosave) - if e.config and e.config.ref_table and e.config.ref_table.ability and e.config.ref_table.ability.name then - sendTraceMessage( - string.format("Client sent message: action:usedCard,card:%s", e.config.ref_table.ability.name), - "MULTIPLAYER" - ) + local card = e.config and e.config.ref_table + if card and card.ability and card.ability.name then + local human = string.format("action:usedCard,card:%s", card.ability.name) + sendTraceMessage("Client sent message: " .. human, "MULTIPLAYER") + -- Pack picks share this hook (a picked card lives in G.pack_cards) but get + -- their own opcode. Both reference a slot plus any highlighted hand targets + -- (e.g. a Tarot from an Arcana pack applied to selected cards). + if card.area == (G and G.pack_cards) then + local idx = MP.UTILS.index_in_area(card, G.pack_cards) + if idx then + local targets = MP.UTILS.highlighted_hand_indices() + MP.RLOG.record("pack_pick", (#targets > 0) and { idx, targets } or { idx }, human) + end + else + local idx = MP.UTILS.index_in_area(card) + if idx then + local targets = MP.UTILS.highlighted_hand_indices() + MP.RLOG.record("use", (#targets > 0) and { idx, targets } or { idx }, human) + end + end end return use_card_ref(e, mute, nosave) end @@ -71,3 +104,58 @@ G.FUNCS.evaluate_round = function() end evaluate_round_ref() end + +-- Carbon: skipping a booster pack. +if G.FUNCS.skip_booster then + local skip_booster_ref = G.FUNCS.skip_booster + function G.FUNCS.skip_booster(e) + MP.RLOG.record("pack_skip", 0, "action:skipPack") + return skip_booster_ref(e) + end +end + +-- Carbon: joker / hand reordering (drag-drop). There is no discrete base-game +-- callback for a reorder, so we diff each area's card order on update. This is +-- intentionally independent of the Preview integration (which has its own, +-- Preview-gated order tracker) so reorders are always logged. Detection is +-- debounced until no card in the area is mid-drag, so one drag emits one event, +-- and reorder_permutation only fires on a pure permutation (the card set is +-- unchanged) -- draws, plays and discards change the set and are ignored here. +local function rlog_reorder_area(cardarea) + if cardarea == G.jokers then return MP.UTILS.AREA.jokers end + if cardarea == G.hand then return MP.UTILS.AREA.hand end + return nil +end + +local function rlog_area_dragging(cardarea) + for _, c in ipairs(cardarea.cards) do + if c.states and c.states.drag and c.states.drag.is then return true end + end + return false +end + +local cardarea_update_ref = CardArea.update +function CardArea:update(dt) + cardarea_update_ref(self, dt) + + -- Cheap area check first (runs for every CardArea every frame); only the + -- joker/hand areas do any further work, and only during a live MP game. + local area_id = rlog_reorder_area(self) + if not area_id or not self.cards or #self.cards == 0 then return end + if not (MP.RLOG and MP.RLOG.is_active()) then return end + if rlog_area_dragging(self) then return end -- wait for the drag to settle + + local cur = {} + for i = 1, #self.cards do + cur[i] = self.cards[i].sort_id + end + local prev = self._rlog_order + self._rlog_order = cur + + if prev and #prev == #cur then + local perm = MP.UTILS.reorder_permutation(prev, self.cards) + if perm then + MP.RLOG.record("reorder", { area_id, perm }, "action:reorder,area:" .. area_id) + end + end +end diff --git a/tests/readme.md b/tests/readme.md index 9ccaebfc0..45ce26357 100644 --- a/tests/readme.md +++ b/tests/readme.md @@ -32,3 +32,30 @@ After `capture`, review the diff in `tests/ruleset_snapshot.lua` before committi - Function bodies (only whether a function is defined) - Runtime behavior (ApplyBans hook chains, smallworld cull logic, speedlatro timer) - Rework center definitions (`MP.ReworkCenter` calls) + +## Replay Log (MP.RLOG) + +`test_rlog_roundtrip.lua` and `test_rlog_checksum.lua` exercise the dual-stream +replay logger (`lib/replay_log.lua`). They stub the game globals, write a real +`.carbon` file into `tests/`, parse it back, and clean up after themselves. + +```bash +lua tests/test_rlog_roundtrip.lua # stream is well-formed + hashes round-trip +lua tests/test_rlog_checksum.lua # editing one opcode changes the stored hash +``` + +`test_rlog_roundtrip.lua` asserts: manifest header + `END`/`CHK` trailer present; +every `A` (positional) line is paired with an `H` (human) line by a gapless, +monotonic sequence number; positional args including ordered index-lists (e.g. +`play 1.3.5.7.8`, `use 1 2.4`) round-trip exactly; the `CHK` per-stream hashes +equal a recompute over the parsed lines and match what `submit_log_hashes` sends. + +`test_rlog_checksum.lua` confirms the `CHK` carbon hash equals a hash of the +carbon stream and that tampering with a single opcode changes it. + +### Manual end-to-end check + +Play one real multiplayer match, then open the `.carbon` file Lovely wrote next +to its log (same name, `.carbon` extension). Read the `A` (positional) and `H` +(human) lines side by side and confirm they mirror each action event-for-event, +and that the player-facing Lovely log itself is unchanged from before. diff --git a/tests/test_rlog_checksum.lua b/tests/test_rlog_checksum.lua new file mode 100644 index 000000000..dd455618c --- /dev/null +++ b/tests/test_rlog_checksum.lua @@ -0,0 +1,71 @@ +--[[ + Replay-log (MP.RLOG) tamper-detection test. + + Verifies that the CHK trailer's carbon hash equals a hash over the carbon + (positional) stream, and that editing a single opcode changes the hash -- i.e. + the cheap end-of-game fingerprint catches a tampered log. (joker_hash is not + collision-resistant; this guards against casual edits, not a determined forger + who also fixes the hash -- see lib/replay_log.lua.) + + Run from the repo root: + lua tests/test_rlog_checksum.lua +]] + +package.loaded["json"] = { encode = function() return "{}" end } + +local CARBON_LOG = "tests/_rlog_checksum.log" +local CARBON_FILE = "tests/_rlog_checksum.carbon" +package.loaded["lovely"] = { log_path = CARBON_LOG } + +function sendTraceMessage() end +function sendWarnMessage() end + +MP = { + LOBBY = { code = "TEST" }, + ACTIONS = {}, + UTILS = { + joker_hash = function(s) + local a, b = 1, 0 + for i = 1, #s do + a = (a + s:byte(i)) % 65521 + b = (b + a) % 65521 + end + return string.format("%08x", b * 65536 + a) + end, + }, +} + +os.remove(CARBON_FILE) +dofile("lib/replay_log.lua") +local RLOG = assert(MP.RLOG, "MP.RLOG not defined after load") + +RLOG.begin_run({ seed = "S", ruleset = "r", gamemode = "g", deck = "d", stake = 1 }) +RLOG.record("buy", { 1, 1 }, "action:boughtCardFromShop,card:X,cost:1") +RLOG.record("sell", { 4, 2 }, "action:soldCard,card:Y") +local carbon_hash = RLOG.end_run({ result = "stop" }) + +local f = assert(io.open(CARBON_FILE, "r"), "carbon file not written") +local content = f:read("*a") +f:close() + +-- Reconstruct the carbon (A-line) stream in order -- this is the hash domain. +local A_full = {} +local chk_carbon +for line in content:gmatch("[^\n]+") do + if line:match("^A ") then + A_full[#A_full + 1] = line + end + chk_carbon = line:match("^CHK v1 carbon=(%x+)") or chk_carbon +end + +local original = table.concat(A_full, "\n") +assert(chk_carbon == carbon_hash, "CHK trailer carbon hash != end_run return") +assert(MP.UTILS.joker_hash(original) == carbon_hash, "CHK carbon hash must equal hash of the A-line stream") + +-- Tamper: buying slot 1 instead becomes slot 2. The hash must change. +local tampered = original:gsub("buy 1 1", "buy 1 2", 1) +assert(tampered ~= original, "tamper precondition failed (pattern not found)") +assert(MP.UTILS.joker_hash(tampered) ~= carbon_hash, "tampered stream must not match the stored hash") + +os.remove(CARBON_FILE) +print("test_rlog_checksum: OK") diff --git a/tests/test_rlog_roundtrip.lua b/tests/test_rlog_roundtrip.lua new file mode 100644 index 000000000..cc5c94a3d --- /dev/null +++ b/tests/test_rlog_roundtrip.lua @@ -0,0 +1,143 @@ +--[[ + Replay-log (MP.RLOG) round-trip test. + + Drives lib/replay_log.lua with stubbed globals, writes a real .carbon file to + tests/, parses it back, and asserts the dual stream is well-formed: manifest + + trailer present, A/H lines paired by a gapless monotonic sequence, positional + args (including ordered index-lists) round-trip exactly, and the CHK trailer's + per-stream hashes equal a recompute over the parsed lines and match what was + submitted to the server. + + Run from the repo root: + lua tests/test_rlog_roundtrip.lua +]] + +-- ─── Stubs ────────────────────────────────────────────────────────────────── + +-- Minimal deterministic encoder; the test manifest/outcome are flat scalars. +package.loaded["json"] = { + encode = function(t) + local keys = {} + for k in pairs(t) do keys[#keys + 1] = k end + table.sort(keys) + local parts = {} + for _, k in ipairs(keys) do + local v = t[k] + local vs + if type(v) == "string" then + vs = '"' .. v .. '"' + elseif type(v) == "table" then + vs = "{}" + else + vs = tostring(v) + end + parts[#parts + 1] = '"' .. k .. '":' .. vs + end + return "{" .. table.concat(parts, ",") .. "}" + end, +} + +local CARBON_LOG = "tests/_rlog_roundtrip.log" +local CARBON_FILE = "tests/_rlog_roundtrip.carbon" +package.loaded["lovely"] = { log_path = CARBON_LOG } + +function sendTraceMessage() end +function sendWarnMessage() end + +local submitted +MP = { + LOBBY = { code = "TEST" }, + ACTIONS = { + submit_log_hashes = function(c, h, seed) + submitted = { carbon = c, human = h, seed = seed } + end, + }, + UTILS = { + -- Real Adler-style hash from lib/crypto.lua so the domain matches prod. + joker_hash = function(s) + local a, b = 1, 0 + for i = 1, #s do + a = (a + s:byte(i)) % 65521 + b = (b + a) % 65521 + end + return string.format("%08x", b * 65536 + a) + end, + }, +} + +os.remove(CARBON_FILE) +dofile("lib/replay_log.lua") + +local RLOG = assert(MP.RLOG, "MP.RLOG not defined after load") + +-- ─── Drive a run ──────────────────────────────────────────────────────────── + +RLOG.begin_run({ seed = "ABCD", ruleset = "r", gamemode = "g", deck = "Red Deck", stake = 1 }) +RLOG.record("select_blind", 0, "action:selectBlind,blind:bl_small") +RLOG.record("buy", { 1, 2 }, "action:boughtCardFromShop,card:Blueprint,cost:4") +RLOG.record("play", { { 1, 3, 5, 7, 8 } }, "action:play,cards:1.3.5.7.8") +RLOG.record("use", { 1, { 2, 4 } }, "action:usedCard,card:The Tower") +RLOG.record("reroll", nil, "action:rerollShop,cost:5") +local carbon_hash, human_hash = RLOG.end_run({ result = "win" }) + +-- ─── Read + parse the carbon file ─────────────────────────────────────────── + +local f = assert(io.open(CARBON_FILE, "r"), "carbon file not written") +local content = f:read("*a") +f:close() + +local lines = {} +for line in content:gmatch("[^\n]+") do + lines[#lines + 1] = line +end + +assert(lines[1]:match("^MANIFEST {"), "first line must be MANIFEST, got: " .. tostring(lines[1])) +assert(lines[#lines - 1]:match("^END {"), "penultimate line must be END, got: " .. tostring(lines[#lines - 1])) +assert(lines[#lines]:match("^CHK v1 carbon=%x+ human=%x+ bytes=%d+$"), "bad CHK: " .. tostring(lines[#lines])) + +local A, H = {}, {} -- seq -> arg string / human payload +local A_full, H_full = {}, {} -- in-order full lines (the hash domain) +local last_seq = 0 +for _, l in ipairs(lines) do + local s, rest = l:match("^A (%d+) (.+)$") + if s then + s = tonumber(s) + A[s] = rest + A_full[#A_full + 1] = l + assert(s == last_seq + 1, "A sequence not gapless/monotonic at " .. s) + last_seq = s + end + local hs, hrest = l:match("^H (%d+) (.+)$") + if hs then + H[tonumber(hs)] = hrest + H_full[#H_full + 1] = l + end +end + +-- ─── Assertions ───────────────────────────────────────────────────────────── + +assert(A[1] == "select_blind 0", "A1=" .. tostring(A[1])) +assert(A[2] == "buy 1 2", "A2=" .. tostring(A[2])) +assert(A[3] == "play 1.3.5.7.8", "A3=" .. tostring(A[3])) -- ordered index-list preserved +assert(A[4] == "use 1 2.4", "A4=" .. tostring(A[4])) -- target index-list preserved +assert(A[5] == "reroll", "A5=" .. tostring(A[5])) -- nil args -> bare opcode +assert(A[6] == nil, "unexpected extra A line") + +for i = 1, 5 do + assert(H[i], "missing paired H line for seq " .. i) +end +assert(H[2] == "action:boughtCardFromShop,card:Blueprint,cost:4", "H2=" .. tostring(H[2])) + +-- Hash domains: CHK values must equal a recompute over the in-order A / H lines. +local chk_carbon, chk_human = lines[#lines]:match("carbon=(%x+) human=(%x+)") +assert(chk_carbon == MP.UTILS.joker_hash(table.concat(A_full, "\n")), "carbon hash domain mismatch") +assert(chk_human == MP.UTILS.joker_hash(table.concat(H_full, "\n")), "human hash domain mismatch") +assert(carbon_hash == chk_carbon and human_hash == chk_human, "end_run return != CHK trailer") + +-- The same hashes are what we send to the server, with the seed for keying. +assert(submitted, "hashes not submitted to server") +assert(submitted.carbon == carbon_hash and submitted.human == human_hash, "submitted hashes mismatch") +assert(submitted.seed == "ABCD", "seed not forwarded to server") + +os.remove(CARBON_FILE) +print("test_rlog_roundtrip: OK") diff --git a/ui/game/functions.lua b/ui/game/functions.lua index 6c2bd2253..f1c9b3512 100644 --- a/ui/game/functions.lua +++ b/ui/game/functions.lua @@ -15,6 +15,7 @@ function G.FUNCS.mp_toggle_ready(e) sendTraceMessage("Toggling Ready", "MULTIPLAYER") MP.GAME.ready_blind = not MP.GAME.ready_blind MP.GAME.ready_blind_text = MP.GAME.ready_blind and localize("b_unready") or localize("b_ready") + MP.RLOG.record("ready_blind", MP.GAME.ready_blind and 1 or 0) MP.GAME.pvp_reached = true @@ -62,6 +63,16 @@ function G.FUNCS.select_blind(e) if MP.is_mp_or_ghost() then MP.GAME.ante_key = tostring(math.random()) if not MP.GHOST.is_active() then + -- Carbon: log the freshly-rolled (non-deterministic) ante_key first so + -- a replay can restore it, then the blind selection itself. + MP.RLOG.record("set_ante_key", MP.GAME.ante_key) + MP.RLOG.record( + "select_blind", + 0, + string.format("action:selectBlind,blind:%s", tostring(e.config.ref_table.key or e.config.ref_table.name)) + ) + -- Flush the carbon file each round to bound data loss on a crash. + MP.RLOG.flush() MP.ACTIONS.play_hand(0, G.GAME.round_resets.hands) MP.ACTIONS.new_round() MP.ACTIONS.set_location("loc_playing", (e.config.ref_table.key or e.config.ref_table.name)) @@ -87,6 +98,7 @@ G.FUNCS.skip_blind = function(e) end MP.ACTIONS.skip(G.GAME.skips) + MP.RLOG.record("skip_blind", 0, "action:skipBlind") --Update the furthest blind local temp_furthest_blind = 0 diff --git a/ui/game/timer.lua b/ui/game/timer.lua index 8665cc76d..cc5bac32f 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -404,7 +404,12 @@ end local old_play = G.FUNCS.play_cards_from_highlighted function G.FUNCS.play_cards_from_highlighted(...) + -- Carbon: capture which hand slots are played BEFORE old_play consumes them. + local played = MP.UTILS.highlighted_hand_indices() old_play(...) + if #played > 0 then + MP.RLOG.record("play", { played }, "action:play,cards:" .. table.concat(played, ".")) + end if G.play and G.play.cards[1] then return end if MP.LOBBY.code and MP.LOBBY.config.timer and not MP.GAME.timer_consumed then if MP.is_pvp_boss() then From e0b34f3b6a4c103c65990b4a2a6bd9f2db5f6a2b Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 10 Jun 2026 18:22:14 +0100 Subject: [PATCH 2/7] refactor(replay): emit both streams into the Lovely log, drop .carbon file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per design change: keep the carbon (replay) and human-readable streams in the same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar. - Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines / CHK trailer); the human stream keeps the existing "Client sent message:" prefix. - record() is now the sole emitter of both lines, so the instrumented overrides (buy/sell/reroll/use) no longer log the human line themselves. - Remove the .carbon file writer, path derivation, flush(), and the per-round flush call. Both stream buffers are still hashed at end_run and re-derive from the log by prefix. - Rework the tests to capture sendTraceMessage output (no file) and update docs. Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4. 🤖 Commit message generated by Claude Code (https://claude.com/claude-code) --- lib/replay_log.lua | 129 ++++++++++++---------------------- overrides/game.lua | 22 +++--- tests/readme.md | 34 +++++---- tests/test_rlog_checksum.lua | 51 +++++++------- tests/test_rlog_roundtrip.lua | 104 ++++++++++----------------- ui/game/functions.lua | 2 - 6 files changed, 136 insertions(+), 206 deletions(-) diff --git a/lib/replay_log.lua b/lib/replay_log.lua index 868af8e05..ad35f6595 100644 --- a/lib/replay_log.lua +++ b/lib/replay_log.lua @@ -1,47 +1,45 @@ -- Replay Log (MP.RLOG): dual-stream, deterministic, fully-recreatable action log. -- -- Two streams are emitted from the SAME instrumentation points so they stay --- event-for-event aligned (shared monotonic sequence number): +-- event-for-event aligned (shared monotonic sequence number). Both go into the +-- ordinary Lovely log, distinguished only by a line prefix so parsers know what +-- to read -- there is no separate file. -- --- 1. Carbon-copy (replay) stream -- positional, no names. "A buy 1 1" --- means "buy shop-jokers slot 1". Indiscriminate, so modded content is --- just "slot N" and replays across mods for free. This is the only truly --- replayable stream. The future replay runner re-runs the game from the --- manifest seed and feeds these opcodes in order. +-- 1. Carbon-copy (replay) stream -- prefix "MP_RLOG:". Positional, no names: +-- "MP_RLOG: 5 buy 1 2" means "buy shop area 1, slot 2". Indiscriminate, so +-- modded content is just "slot N" and replays across mods for free. This is +-- the only truly replayable stream. The block is framed by a MANIFEST +-- header and an END + CHK trailer (also under the MP_RLOG: prefix). -- --- 2. Human-readable stream -- the semantic mirror. "H action:..." keeps --- the existing log payload format so the website parser stays compatible. +-- 2. Human-readable stream -- prefix "Client sent message:" (the existing +-- format the website parser already reads). "Client sent message: action: +-- boughtCardFromShop,card:Blueprint,cost:4". -- --- BOTH streams live in a dedicated ".carbon" sidecar next to the Lovely log --- (NOT in the mod folder, so version hotswaps don't disturb it). The .carbon --- file is the self-contained dev/verification artifact: manifest header, the A --- and H lines, then a trailer with a separate hash of each stream. --- --- The player-facing Lovely log is left 100% unchanged: the existing per-action --- sendTraceMessage calls keep emitting the human lines players read, and the new --- carbon pipe never touches it. record() therefore does NOT write to the Lovely --- log -- it only appends to the .carbon file. Pass record() the same human --- payload those existing log lines use so the carbon H stream mirrors them. +-- record() is the single emitter for both lines, so the per-action overrides no +-- longer log the human line themselves -- they pass the payload to record(). -- -- At end_run both streams are hashed and the hashes are sent to the server, so -- tamper-checking later is a cheap hash comparison instead of a line-by-line --- diff. NOTE: MP.UTILS.joker_hash (Adler-style) catches casual edits but is NOT --- collision-resistant -- a motivated forger could edit and fix the hash. The --- robust defenses (server sees the lines live, or full re-simulation) are future --- work; this hash is the intended cheap first pass. +-- diff. The carbon stream re-derives cleanly from the log by its prefix. NOTE: +-- MP.UTILS.joker_hash (Adler-style) catches casual edits but is NOT collision- +-- resistant -- a motivated forger could edit and fix the hash. The robust +-- defenses (server sees the lines live, or full re-simulation) are future work; +-- this hash is the intended cheap first pass. local RLOG = {} MP.RLOG = RLOG +RLOG.CARBON_PREFIX = "MP_RLOG:" -- positional / replay stream +RLOG.HUMAN_PREFIX = "Client sent message:" -- human-readable stream (website-compatible) + -- Required manifest keys; begin_run warns if any are missing. RLOG.REQUIRED_MANIFEST_KEYS = { "seed", "ruleset", "gamemode", "deck", "stake" } RLOG._seq = 0 -RLOG._carbon_buffer = {} -- the "A " lines (positional stream), hashed at end -RLOG._human_buffer = {} -- the "H " lines (human stream), hashed at end -RLOG._pending = {} -- lines awaiting flush to the .carbon file -RLOG._carbon_path = nil +RLOG._carbon_buffer = {} -- the action "MP_RLOG: ..." lines, hashed at end +RLOG._human_buffer = {} -- the "Client sent message: ..." lines, hashed at end RLOG._run_active = false +RLOG._manifest = nil RLOG._force_active = false -- test hook: bypass the lobby gate ------------------------------------------------------------------------------- @@ -61,16 +59,6 @@ end -- Internal helpers ------------------------------------------------------------------------------- --- Derive the carbon sidecar path from the Lovely log path by swapping the --- trailing extension for ".carbon" (same folder, same base name). -local function derive_carbon_path() - local ok, lovely = pcall(require, "lovely") - if not ok or not lovely or not lovely.log_path then return nil end - -- Strip a trailing "." on the final path segment only, then append. - local base = lovely.log_path:gsub("%.[^%.\\/]*$", "") - return base .. ".carbon" -end - -- Format an opcode's args into the positional arg string. -- Each token is either a scalar -> "1" or a list -> dot-joined "1.3.5". -- A bare scalar/string is treated as a single token. @@ -92,49 +80,17 @@ local function fmt_args(args) return table.concat(parts, " ") end -local function push_carbon(line) - RLOG._carbon_buffer[#RLOG._carbon_buffer + 1] = line - RLOG._pending[#RLOG._pending + 1] = line -end - -local function push_human(line) - RLOG._human_buffer[#RLOG._human_buffer + 1] = line - RLOG._pending[#RLOG._pending + 1] = line -end - --- Structural lines (MANIFEST/END/CHK) go to the file but are not part of the --- per-stream hash buffers. -local function push_raw(line) - RLOG._pending[#RLOG._pending + 1] = line -end - -------------------------------------------------------------------------------- --- File writing -------------------------------------------------------------------------------- - --- Append any buffered lines to the .carbon file. Append-mode so multiple games --- in one Lovely session accumulate as sequential blocks. Called on begin_run, --- once per round, and on end_run to bound data loss on crash. -function RLOG.flush() - if #RLOG._pending == 0 then return end - if not RLOG._carbon_path then return end - local f = io.open(RLOG._carbon_path, "a") - if not f then - sendWarnMessage("RLOG: could not open carbon file " .. tostring(RLOG._carbon_path), "MULTIPLAYER") - return - end - f:write(table.concat(RLOG._pending, "\n") .. "\n") - f:close() - RLOG._pending = {} +local function emit(msg) + sendTraceMessage(msg, "MULTIPLAYER") end ------------------------------------------------------------------------------- -- Public API ------------------------------------------------------------------------------- --- Record one state-affecting action. Writes the carbon (positional) line and, +-- Record one state-affecting action. Emits the carbon (positional) line and, -- when a human payload is provided, the mirrored human line -- both into the --- .carbon file with the same sequence number. Never writes to the Lovely log. +-- Lovely log with the same sequence number. -- opcode : string, e.g. "buy" -- args : nil | scalar | list of tokens (scalar or sub-list); see fmt_args -- human : nil | string payload in the existing "action:key,..." format @@ -145,13 +101,18 @@ function RLOG.record(opcode, args, human) local seq = RLOG._seq local argstr = fmt_args(args) - push_carbon("A " .. seq .. " " .. opcode .. (argstr ~= "" and (" " .. argstr) or "")) - - if human ~= nil and human ~= "" then push_human("H " .. seq .. " " .. human) end + local cline = RLOG.CARBON_PREFIX .. " " .. seq .. " " .. opcode .. (argstr ~= "" and (" " .. argstr) or "") + RLOG._carbon_buffer[#RLOG._carbon_buffer + 1] = cline + emit(cline) + + if human ~= nil and human ~= "" then + local hline = RLOG.HUMAN_PREFIX .. " " .. human + RLOG._human_buffer[#RLOG._human_buffer + 1] = hline + emit(hline) + end end --- Start a new game's block. Resets counters/buffers, resolves the carbon path, --- writes the manifest header, and flushes it immediately. +-- Start a new game's block: reset counters/buffers and emit the manifest header. function RLOG.begin_run(manifest) manifest = manifest or {} @@ -164,23 +125,20 @@ function RLOG.begin_run(manifest) RLOG._seq = 0 RLOG._carbon_buffer = {} RLOG._human_buffer = {} - RLOG._pending = {} - RLOG._carbon_path = derive_carbon_path() RLOG._manifest = manifest RLOG._run_active = true local json = require("json") - push_raw("MANIFEST " .. json.encode(manifest)) - RLOG.flush() + emit(RLOG.CARBON_PREFIX .. " MANIFEST " .. json.encode(manifest)) end --- Close the current game's block: write the END line, hash each stream, write --- the CHK trailer, flush, and submit both hashes to the server. +-- Close the current game's block: emit the END line, hash each stream, emit the +-- CHK trailer, and submit both hashes to the server. function RLOG.end_run(outcome) if not RLOG._run_active then return end local json = require("json") - push_raw("END " .. json.encode(outcome or {})) + emit(RLOG.CARBON_PREFIX .. " END " .. json.encode(outcome or {})) local carbon_str = table.concat(RLOG._carbon_buffer, "\n") local human_str = table.concat(RLOG._human_buffer, "\n") @@ -188,8 +146,7 @@ function RLOG.end_run(outcome) local human_hash = MP.UTILS.joker_hash(human_str) local bytes = #carbon_str + #human_str - push_raw(string.format("CHK v1 carbon=%s human=%s bytes=%d", carbon_hash, human_hash, bytes)) - RLOG.flush() + emit(string.format("%s CHK v1 carbon=%s human=%s bytes=%d", RLOG.CARBON_PREFIX, carbon_hash, human_hash, bytes)) if MP.ACTIONS and MP.ACTIONS.submit_log_hashes then MP.ACTIONS.submit_log_hashes(carbon_hash, human_hash, RLOG._manifest and RLOG._manifest.seed) diff --git a/overrides/game.lua b/overrides/game.lua index 36002b346..263793988 100644 --- a/overrides/game.lua +++ b/overrides/game.lua @@ -15,10 +15,10 @@ end local sell_card_ref = Card.sell_card function Card:sell_card() if self.ability and self.ability.name then - local human = string.format("action:soldCard,card:%s", self.ability.name) - sendTraceMessage("Client sent message: " .. human, "MULTIPLAYER") - -- Carbon: positional sell by area + slot, captured before the card leaves + -- record() emits both the carbon line and the human "Client sent message:" + -- line. Sell is positional by area + slot, captured before the card leaves -- its area. Area distinguishes selling a joker (4) from a consumable (5). + local human = string.format("action:soldCard,card:%s", self.ability.name) local area = MP.UTILS.area_enum(self.area) local idx = MP.UTILS.index_in_area(self) if area and idx then MP.RLOG.record("sell", { area, idx }, human) end @@ -28,12 +28,10 @@ end local reroll_shop_ref = G.FUNCS.reroll_shop function G.FUNCS.reroll_shop(e) - local human = string.format("action:rerollShop,cost:%s", G.GAME.current_round.reroll_cost) - sendTraceMessage("Client sent message: " .. human, "MULTIPLAYER") - - -- Carbon: reroll has no positional target; the shop contents it produces are + -- Reroll has no positional target; the shop contents it produces are -- deterministic from the seed, so the bare opcode is enough to replay. - MP.RLOG.record("reroll", nil, human) + -- record() emits both the carbon line and the human "Client sent message:". + MP.RLOG.record("reroll", nil, string.format("action:rerollShop,cost:%s", G.GAME.current_round.reroll_cost)) -- Update reroll stats if in a multiplayer game if MP.LOBBY.code and MP.GAME.stats then @@ -48,11 +46,11 @@ local buy_from_shop_ref = G.FUNCS.buy_from_shop function G.FUNCS.buy_from_shop(e) local c1 = e.config.ref_table if c1 and c1:is(Card) then - local human = string.format("action:boughtCardFromShop,card:%s,cost:%s", c1.ability.name, c1.cost) - sendTraceMessage("Client sent message: " .. human, "MULTIPLAYER") - -- Carbon: positional buy by shop area + slot, captured before the card + -- record() emits both the carbon line and the human "Client sent message:" + -- line. Buy is positional by shop area + slot, captured before the card -- leaves the shop. Booster packs and vouchers get distinct opcodes since -- they branch the game differently, but all reference an area + slot. + local human = string.format("action:boughtCardFromShop,card:%s,cost:%s", c1.ability.name, c1.cost) local area = MP.UTILS.area_enum(c1.area) local idx = MP.UTILS.index_in_area(c1) if area and idx then @@ -73,8 +71,8 @@ local use_card_ref = G.FUNCS.use_card function G.FUNCS.use_card(e, mute, nosave) local card = e.config and e.config.ref_table if card and card.ability and card.ability.name then + -- record() emits both the carbon line and the human "Client sent message:". local human = string.format("action:usedCard,card:%s", card.ability.name) - sendTraceMessage("Client sent message: " .. human, "MULTIPLAYER") -- Pack picks share this hook (a picked card lives in G.pack_cards) but get -- their own opcode. Both reference a slot plus any highlighted hand targets -- (e.g. a Tarot from an Arcana pack applied to selected cards). diff --git a/tests/readme.md b/tests/readme.md index 45ce26357..8ade6ff30 100644 --- a/tests/readme.md +++ b/tests/readme.md @@ -36,26 +36,34 @@ After `capture`, review the diff in `tests/ruleset_snapshot.lua` before committi ## Replay Log (MP.RLOG) `test_rlog_roundtrip.lua` and `test_rlog_checksum.lua` exercise the dual-stream -replay logger (`lib/replay_log.lua`). They stub the game globals, write a real -`.carbon` file into `tests/`, parse it back, and clean up after themselves. +replay logger (`lib/replay_log.lua`). They stub the game globals, capture the +lines it emits to the Lovely log, and assert on them — no files are written. + +Both streams live in the ordinary Lovely log, distinguished by prefix: +- **Carbon (positional/replay):** `MP_RLOG:` — e.g. `MP_RLOG: 5 buy 1 2`, plus + `MP_RLOG: MANIFEST {...}` and the `MP_RLOG: CHK v1 carbon=… human=… bytes=…` + trailer. +- **Human-readable:** `Client sent message:` — the existing website-parser + format, e.g. `Client sent message: action:boughtCardFromShop,card:Blueprint,cost:4`. ```bash -lua tests/test_rlog_roundtrip.lua # stream is well-formed + hashes round-trip +lua tests/test_rlog_roundtrip.lua # streams well-formed + hashes round-trip lua tests/test_rlog_checksum.lua # editing one opcode changes the stored hash ``` -`test_rlog_roundtrip.lua` asserts: manifest header + `END`/`CHK` trailer present; -every `A` (positional) line is paired with an `H` (human) line by a gapless, -monotonic sequence number; positional args including ordered index-lists (e.g. -`play 1.3.5.7.8`, `use 1 2.4`) round-trip exactly; the `CHK` per-stream hashes -equal a recompute over the parsed lines and match what `submit_log_hashes` sends. +`test_rlog_roundtrip.lua` asserts: `MP_RLOG: MANIFEST` header + `END`/`CHK` +trailer; carbon action lines with a gapless, monotonic sequence; positional args +including ordered index-lists (e.g. `play 1.3.5.7.8`, `use 1 2.4`) intact; a +paired `Client sent message:` line per action; and `CHK` per-stream hashes that +equal a recompute over the captured lines and match what `submit_log_hashes` +sends. `test_rlog_checksum.lua` confirms the `CHK` carbon hash equals a hash of the -carbon stream and that tampering with a single opcode changes it. +carbon stream (re-extracted by prefix) and that tampering with one opcode +changes it. ### Manual end-to-end check -Play one real multiplayer match, then open the `.carbon` file Lovely wrote next -to its log (same name, `.carbon` extension). Read the `A` (positional) and `H` -(human) lines side by side and confirm they mirror each action event-for-event, -and that the player-facing Lovely log itself is unchanged from before. +Play one real multiplayer match, then open the Lovely log. Filter the `MP_RLOG:` +(positional) and `Client sent message:` (human) lines and confirm they mirror +each action event-for-event. diff --git a/tests/test_rlog_checksum.lua b/tests/test_rlog_checksum.lua index dd455618c..f6be16644 100644 --- a/tests/test_rlog_checksum.lua +++ b/tests/test_rlog_checksum.lua @@ -1,23 +1,27 @@ --[[ Replay-log (MP.RLOG) tamper-detection test. - Verifies that the CHK trailer's carbon hash equals a hash over the carbon - (positional) stream, and that editing a single opcode changes the hash -- i.e. - the cheap end-of-game fingerprint catches a tampered log. (joker_hash is not - collision-resistant; this guards against casual edits, not a determined forger - who also fixes the hash -- see lib/replay_log.lua.) + Verifies the CHK trailer's carbon hash equals a hash over the carbon + (positional) stream as re-extracted from the log by its "MP_RLOG:" prefix, and + that editing a single opcode changes the hash -- i.e. the cheap end-of-game + fingerprint catches a tampered log. (joker_hash is not collision-resistant; + this guards against casual edits, not a determined forger who also fixes the + hash -- see lib/replay_log.lua.) Run from the repo root: lua tests/test_rlog_checksum.lua ]] -package.loaded["json"] = { encode = function() return "{}" end } - -local CARBON_LOG = "tests/_rlog_checksum.log" -local CARBON_FILE = "tests/_rlog_checksum.carbon" -package.loaded["lovely"] = { log_path = CARBON_LOG } +package.loaded["json"] = { + encode = function() + return "{}" + end, +} -function sendTraceMessage() end +local captured = {} +function sendTraceMessage(msg) + captured[#captured + 1] = msg +end function sendWarnMessage() end MP = { @@ -35,7 +39,6 @@ MP = { }, } -os.remove(CARBON_FILE) dofile("lib/replay_log.lua") local RLOG = assert(MP.RLOG, "MP.RLOG not defined after load") @@ -44,28 +47,24 @@ RLOG.record("buy", { 1, 1 }, "action:boughtCardFromShop,card:X,cost:1") RLOG.record("sell", { 4, 2 }, "action:soldCard,card:Y") local carbon_hash = RLOG.end_run({ result = "stop" }) -local f = assert(io.open(CARBON_FILE, "r"), "carbon file not written") -local content = f:read("*a") -f:close() - --- Reconstruct the carbon (A-line) stream in order -- this is the hash domain. -local A_full = {} +-- Re-extract the carbon (positional) stream from the log by prefix: action +-- lines are "MP_RLOG: ...", excluding MANIFEST/END/CHK. This is the domain. +local carbon_lines = {} local chk_carbon -for line in content:gmatch("[^\n]+") do - if line:match("^A ") then - A_full[#A_full + 1] = line +for _, l in ipairs(captured) do + if l:match("^MP_RLOG: %d") then + carbon_lines[#carbon_lines + 1] = l end - chk_carbon = line:match("^CHK v1 carbon=(%x+)") or chk_carbon + chk_carbon = l:match("^MP_RLOG: CHK v1 carbon=(%x+)") or chk_carbon end -local original = table.concat(A_full, "\n") +local original = table.concat(carbon_lines, "\n") assert(chk_carbon == carbon_hash, "CHK trailer carbon hash != end_run return") -assert(MP.UTILS.joker_hash(original) == carbon_hash, "CHK carbon hash must equal hash of the A-line stream") +assert(MP.UTILS.joker_hash(original) == carbon_hash, "CHK carbon hash must equal hash of the carbon stream") -- Tamper: buying slot 1 instead becomes slot 2. The hash must change. -local tampered = original:gsub("buy 1 1", "buy 1 2", 1) +local tampered = original:gsub("1 buy 1 1", "1 buy 1 2", 1) assert(tampered ~= original, "tamper precondition failed (pattern not found)") assert(MP.UTILS.joker_hash(tampered) ~= carbon_hash, "tampered stream must not match the stored hash") -os.remove(CARBON_FILE) print("test_rlog_checksum: OK") diff --git a/tests/test_rlog_roundtrip.lua b/tests/test_rlog_roundtrip.lua index cc5c94a3d..727c86ca2 100644 --- a/tests/test_rlog_roundtrip.lua +++ b/tests/test_rlog_roundtrip.lua @@ -1,47 +1,28 @@ --[[ Replay-log (MP.RLOG) round-trip test. - Drives lib/replay_log.lua with stubbed globals, writes a real .carbon file to - tests/, parses it back, and asserts the dual stream is well-formed: manifest + - trailer present, A/H lines paired by a gapless monotonic sequence, positional - args (including ordered index-lists) round-trip exactly, and the CHK trailer's - per-stream hashes equal a recompute over the parsed lines and match what was - submitted to the server. + Drives lib/replay_log.lua with stubbed globals, captures the lines it emits to + the Lovely log, and asserts the dual stream is well-formed: a MANIFEST header + and END + CHK trailer under the "MP_RLOG:" carbon prefix; carbon action lines + with a gapless monotonic sequence; positional args (including ordered index- + lists) intact; a paired human "Client sent message:" line per action; and CHK + per-stream hashes that equal a recompute over the captured lines and match + what was submitted to the server. Run from the repo root: lua tests/test_rlog_roundtrip.lua ]] --- ─── Stubs ────────────────────────────────────────────────────────────────── - --- Minimal deterministic encoder; the test manifest/outcome are flat scalars. package.loaded["json"] = { - encode = function(t) - local keys = {} - for k in pairs(t) do keys[#keys + 1] = k end - table.sort(keys) - local parts = {} - for _, k in ipairs(keys) do - local v = t[k] - local vs - if type(v) == "string" then - vs = '"' .. v .. '"' - elseif type(v) == "table" then - vs = "{}" - else - vs = tostring(v) - end - parts[#parts + 1] = '"' .. k .. '":' .. vs - end - return "{" .. table.concat(parts, ",") .. "}" + encode = function() + return "{}" end, } -local CARBON_LOG = "tests/_rlog_roundtrip.log" -local CARBON_FILE = "tests/_rlog_roundtrip.carbon" -package.loaded["lovely"] = { log_path = CARBON_LOG } - -function sendTraceMessage() end +local captured = {} +function sendTraceMessage(msg) + captured[#captured + 1] = msg +end function sendWarnMessage() end local submitted @@ -65,9 +46,7 @@ MP = { }, } -os.remove(CARBON_FILE) dofile("lib/replay_log.lua") - local RLOG = assert(MP.RLOG, "MP.RLOG not defined after load") -- ─── Drive a run ──────────────────────────────────────────────────────────── @@ -80,37 +59,30 @@ RLOG.record("use", { 1, { 2, 4 } }, "action:usedCard,card:The Tower") RLOG.record("reroll", nil, "action:rerollShop,cost:5") local carbon_hash, human_hash = RLOG.end_run({ result = "win" }) --- ─── Read + parse the carbon file ─────────────────────────────────────────── +-- ─── Parse the captured log lines ─────────────────────────────────────────── -local f = assert(io.open(CARBON_FILE, "r"), "carbon file not written") -local content = f:read("*a") -f:close() +assert(captured[1]:match("^MP_RLOG: MANIFEST {"), "first line must be MANIFEST, got: " .. tostring(captured[1])) +assert(captured[#captured - 1]:match("^MP_RLOG: END {"), "penultimate must be END, got: " .. tostring(captured[#captured - 1])) +assert( + captured[#captured]:match("^MP_RLOG: CHK v1 carbon=%x+ human=%x+ bytes=%d+$"), + "bad CHK: " .. tostring(captured[#captured]) +) -local lines = {} -for line in content:gmatch("[^\n]+") do - lines[#lines + 1] = line -end - -assert(lines[1]:match("^MANIFEST {"), "first line must be MANIFEST, got: " .. tostring(lines[1])) -assert(lines[#lines - 1]:match("^END {"), "penultimate line must be END, got: " .. tostring(lines[#lines - 1])) -assert(lines[#lines]:match("^CHK v1 carbon=%x+ human=%x+ bytes=%d+$"), "bad CHK: " .. tostring(lines[#lines])) - -local A, H = {}, {} -- seq -> arg string / human payload -local A_full, H_full = {}, {} -- in-order full lines (the hash domain) +local A = {} -- seq -> arg string +local carbon_lines, human_lines = {}, {} -- in-order full lines (the hash domains) local last_seq = 0 -for _, l in ipairs(lines) do - local s, rest = l:match("^A (%d+) (.+)$") +for _, l in ipairs(captured) do + local s, rest = l:match("^MP_RLOG: (%d+) (.+)$") if s then s = tonumber(s) A[s] = rest - A_full[#A_full + 1] = l - assert(s == last_seq + 1, "A sequence not gapless/monotonic at " .. s) + carbon_lines[#carbon_lines + 1] = l + assert(s == last_seq + 1, "carbon sequence not gapless/monotonic at " .. s) last_seq = s end - local hs, hrest = l:match("^H (%d+) (.+)$") - if hs then - H[tonumber(hs)] = hrest - H_full[#H_full + 1] = l + local payload = l:match("^Client sent message: (.+)$") + if payload then + human_lines[#human_lines + 1] = l end end @@ -121,17 +93,16 @@ assert(A[2] == "buy 1 2", "A2=" .. tostring(A[2])) assert(A[3] == "play 1.3.5.7.8", "A3=" .. tostring(A[3])) -- ordered index-list preserved assert(A[4] == "use 1 2.4", "A4=" .. tostring(A[4])) -- target index-list preserved assert(A[5] == "reroll", "A5=" .. tostring(A[5])) -- nil args -> bare opcode -assert(A[6] == nil, "unexpected extra A line") +assert(A[6] == nil, "unexpected extra carbon action line") -for i = 1, 5 do - assert(H[i], "missing paired H line for seq " .. i) -end -assert(H[2] == "action:boughtCardFromShop,card:Blueprint,cost:4", "H2=" .. tostring(H[2])) +assert(#human_lines == 5, "expected 5 human lines, got " .. #human_lines) +assert(human_lines[2] == "Client sent message: action:boughtCardFromShop,card:Blueprint,cost:4", "H2=" .. human_lines[2]) --- Hash domains: CHK values must equal a recompute over the in-order A / H lines. -local chk_carbon, chk_human = lines[#lines]:match("carbon=(%x+) human=(%x+)") -assert(chk_carbon == MP.UTILS.joker_hash(table.concat(A_full, "\n")), "carbon hash domain mismatch") -assert(chk_human == MP.UTILS.joker_hash(table.concat(H_full, "\n")), "human hash domain mismatch") +-- Hash domains: CHK values must equal a recompute over the in-order carbon / +-- human lines (exactly what gets re-extracted from a log by prefix). +local chk_carbon, chk_human = captured[#captured]:match("carbon=(%x+) human=(%x+)") +assert(chk_carbon == MP.UTILS.joker_hash(table.concat(carbon_lines, "\n")), "carbon hash domain mismatch") +assert(chk_human == MP.UTILS.joker_hash(table.concat(human_lines, "\n")), "human hash domain mismatch") assert(carbon_hash == chk_carbon and human_hash == chk_human, "end_run return != CHK trailer") -- The same hashes are what we send to the server, with the seed for keying. @@ -139,5 +110,4 @@ assert(submitted, "hashes not submitted to server") assert(submitted.carbon == carbon_hash and submitted.human == human_hash, "submitted hashes mismatch") assert(submitted.seed == "ABCD", "seed not forwarded to server") -os.remove(CARBON_FILE) print("test_rlog_roundtrip: OK") diff --git a/ui/game/functions.lua b/ui/game/functions.lua index f1c9b3512..cd018ac69 100644 --- a/ui/game/functions.lua +++ b/ui/game/functions.lua @@ -71,8 +71,6 @@ function G.FUNCS.select_blind(e) 0, string.format("action:selectBlind,blind:%s", tostring(e.config.ref_table.key or e.config.ref_table.name)) ) - -- Flush the carbon file each round to bound data loss on a crash. - MP.RLOG.flush() MP.ACTIONS.play_hand(0, G.GAME.round_resets.hands) MP.ACTIONS.new_round() MP.ACTIONS.set_location("loc_playing", (e.config.ref_table.key or e.config.ref_table.name)) From f707cdffdc4ddd57ceadd5b0fe12b1c5b3478ab6 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 10 Jun 2026 20:43:11 +0100 Subject: [PATCH 3/7] feat(replay): ship the full carbon log to the server, not just its hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit end_run now accumulates the full carbon block (manifest + action lines + END + CHK) and sends it as a `log` field on submitLogHashes, so the server can store a complete viewable/replayable record of every game without the player handing over their log. Hashing is unchanged (still over the action/human buffers). 🤖 Commit message generated by Claude Code (https://claude.com/claude-code) --- lib/replay_log.lua | 25 +++++++++++++++++++------ networking/action_handlers.lua | 3 ++- tests/test_rlog_roundtrip.lua | 11 +++++++++-- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/lib/replay_log.lua b/lib/replay_log.lua index ad35f6595..e214ed4bc 100644 --- a/lib/replay_log.lua +++ b/lib/replay_log.lua @@ -37,6 +37,7 @@ RLOG.REQUIRED_MANIFEST_KEYS = { "seed", "ruleset", "gamemode", "deck", "stake" } RLOG._seq = 0 RLOG._carbon_buffer = {} -- the action "MP_RLOG: ..." lines, hashed at end +RLOG._carbon_full = {} -- the full carbon block (manifest + actions + END + CHK), sent to the server RLOG._human_buffer = {} -- the "Client sent message: ..." lines, hashed at end RLOG._run_active = false RLOG._manifest = nil @@ -84,6 +85,14 @@ local function emit(msg) sendTraceMessage(msg, "MULTIPLAYER") end +-- Emit a carbon-stream line: tee to the Lovely log AND accumulate it into the +-- full block we ship to the server at end_run (so the server keeps the whole +-- viewable/replayable action log, not just its hash). +local function emit_carbon(msg) + RLOG._carbon_full[#RLOG._carbon_full + 1] = msg + sendTraceMessage(msg, "MULTIPLAYER") +end + ------------------------------------------------------------------------------- -- Public API ------------------------------------------------------------------------------- @@ -103,7 +112,7 @@ function RLOG.record(opcode, args, human) local argstr = fmt_args(args) local cline = RLOG.CARBON_PREFIX .. " " .. seq .. " " .. opcode .. (argstr ~= "" and (" " .. argstr) or "") RLOG._carbon_buffer[#RLOG._carbon_buffer + 1] = cline - emit(cline) + emit_carbon(cline) if human ~= nil and human ~= "" then local hline = RLOG.HUMAN_PREFIX .. " " .. human @@ -124,21 +133,22 @@ function RLOG.begin_run(manifest) RLOG._seq = 0 RLOG._carbon_buffer = {} + RLOG._carbon_full = {} RLOG._human_buffer = {} RLOG._manifest = manifest RLOG._run_active = true local json = require("json") - emit(RLOG.CARBON_PREFIX .. " MANIFEST " .. json.encode(manifest)) + emit_carbon(RLOG.CARBON_PREFIX .. " MANIFEST " .. json.encode(manifest)) end -- Close the current game's block: emit the END line, hash each stream, emit the --- CHK trailer, and submit both hashes to the server. +-- CHK trailer, and submit the hashes plus the full carbon block to the server. function RLOG.end_run(outcome) if not RLOG._run_active then return end local json = require("json") - emit(RLOG.CARBON_PREFIX .. " END " .. json.encode(outcome or {})) + emit_carbon(RLOG.CARBON_PREFIX .. " END " .. json.encode(outcome or {})) local carbon_str = table.concat(RLOG._carbon_buffer, "\n") local human_str = table.concat(RLOG._human_buffer, "\n") @@ -146,10 +156,13 @@ function RLOG.end_run(outcome) local human_hash = MP.UTILS.joker_hash(human_str) local bytes = #carbon_str + #human_str - emit(string.format("%s CHK v1 carbon=%s human=%s bytes=%d", RLOG.CARBON_PREFIX, carbon_hash, human_hash, bytes)) + emit_carbon(string.format("%s CHK v1 carbon=%s human=%s bytes=%d", RLOG.CARBON_PREFIX, carbon_hash, human_hash, bytes)) if MP.ACTIONS and MP.ACTIONS.submit_log_hashes then - MP.ACTIONS.submit_log_hashes(carbon_hash, human_hash, RLOG._manifest and RLOG._manifest.seed) + -- The full carbon block (manifest + actions + END + CHK) so the server + -- keeps the complete viewable/replayable log, not just its hash. + local carbon_log = table.concat(RLOG._carbon_full, "\n") + MP.ACTIONS.submit_log_hashes(carbon_hash, human_hash, RLOG._manifest and RLOG._manifest.seed, carbon_log) end RLOG._run_active = false diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 869f1cc57..56f7b2d0d 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -1318,12 +1318,13 @@ end -- End-of-game replay-log fingerprints. The server stores these (keyed by -- lobby + seed + game) so a presented log can later be re-hashed and compared -- without a line-by-line diff. See lib/replay_log.lua (MP.RLOG). -function MP.ACTIONS.submit_log_hashes(carbon, human, seed) +function MP.ACTIONS.submit_log_hashes(carbon, human, seed, log) Client.send({ action = "submitLogHashes", carbon = carbon, human = human, seed = seed, + log = log, }) end diff --git a/tests/test_rlog_roundtrip.lua b/tests/test_rlog_roundtrip.lua index 727c86ca2..7525e718e 100644 --- a/tests/test_rlog_roundtrip.lua +++ b/tests/test_rlog_roundtrip.lua @@ -29,8 +29,8 @@ local submitted MP = { LOBBY = { code = "TEST" }, ACTIONS = { - submit_log_hashes = function(c, h, seed) - submitted = { carbon = c, human = h, seed = seed } + submit_log_hashes = function(c, h, seed, log) + submitted = { carbon = c, human = h, seed = seed, log = log } end, }, UTILS = { @@ -110,4 +110,11 @@ assert(submitted, "hashes not submitted to server") assert(submitted.carbon == carbon_hash and submitted.human == human_hash, "submitted hashes mismatch") assert(submitted.seed == "ABCD", "seed not forwarded to server") +-- The full carbon block (manifest + actions + END + CHK) is shipped to the server +-- so it keeps the whole viewable/replayable log, not just the hash. +assert(submitted.log, "carbon log not sent to server") +assert(submitted.log:match("MP_RLOG: MANIFEST {"), "sent log missing manifest line") +assert(submitted.log:match("MP_RLOG: 2 buy 1 2"), "sent log missing action lines") +assert(submitted.log:match("MP_RLOG: CHK v1 "), "sent log missing CHK trailer") + print("test_rlog_roundtrip: OK") From a2a54cb0c9580a5afd50868b4cf6ce2dc8d3d555 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Mon, 15 Jun 2026 21:05:32 +0100 Subject: [PATCH 4/7] feat(serialization): guard str_decode_and_unpack against zip-bomb payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers / receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client. A legitimate saved joker / nemesis deck is a few KB; both callers already bail cleanly on the nil return. Defense-in-depth — the relay also caps message size. Adds tests/test_serialization_guard.lua. 🤖 Commit message generated by Claude Code (https://claude.com/claude-code) --- lib/serialization.lua | 12 +++++ tests/test_serialization_guard.lua | 84 ++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/test_serialization_guard.lua diff --git a/lib/serialization.lua b/lib/serialization.lua index 42856d384..ac5b628c4 100644 --- a/lib/serialization.lua +++ b/lib/serialization.lua @@ -44,8 +44,20 @@ function MP.UTILS.str_pack_and_encode(data) return str_encoded end +-- A legitimately serialized object (a saved joker, a nemesis deck) is at most a +-- few KB once gzipped and base64-encoded. Reject anything far larger BEFORE we +-- spend CPU decoding/decompressing it: this is the client-side defense against +-- zip-bomb payloads relayed through actions like magnetResponse / +-- receiveEndGameJokers / receiveNemesisDeck. The relay also caps message size, +-- so this is defense-in-depth. +MP.UTILS.MAX_ENCODED_BYTES = 32 * 1024 + function MP.UTILS.str_decode_and_unpack(str) local success, str_decoded, str_decompressed, str_unpacked + if type(str) ~= "string" then return nil, "expected string payload" end + if #str > MP.UTILS.MAX_ENCODED_BYTES then + return nil, string.format("payload too large (%d > %d bytes)", #str, MP.UTILS.MAX_ENCODED_BYTES) + end success, str_decoded = pcall(love.data.decode, "string", "base64", str) if not success then return nil, str_decoded end success, str_decompressed = pcall(love.data.decompress, "string", "gzip", str_decoded) diff --git a/tests/test_serialization_guard.lua b/tests/test_serialization_guard.lua new file mode 100644 index 000000000..9c9d27943 --- /dev/null +++ b/tests/test_serialization_guard.lua @@ -0,0 +1,84 @@ +--[[ + Serialization zip-bomb guard test. + + Exercises MP.UTILS.str_decode_and_unpack's size guard (lib/serialization.lua): + an over-large encoded payload must be rejected BEFORE any base64/gzip work, a + non-string must be rejected, and a normal small payload must still round-trip. + + love.data and STR_PACK are stubbed with identity codecs so the test runs under + plain Lua (the guard logic is what we're covering, not love's real codecs). + + Run from the repo root: + lua tests/test_serialization_guard.lua +]] + +MP = { UTILS = {} } + +-- Track whether the heavy decode path was entered, so we can prove the guard +-- short-circuits before spending any CPU on an oversized payload. +local decode_calls = 0 + +love = { + data = { + decode = function(_container, _fmt, s) + decode_calls = decode_calls + 1 + return s + end, + decompress = function(_container, _fmt, s) + return s + end, + compress = function(_container, _fmt, s) + return s + end, + encode = function(_container, _fmt, s) + return s + end, + }, +} + +-- Minimal STR_PACK for a flat table of string/number values -> "return { ... }". +function STR_PACK(data) + local parts = {} + for k, v in pairs(data) do + local key = string.format("[%q]", k) + local val = type(v) == "number" and tostring(v) or string.format("%q", v) + parts[#parts + 1] = key .. "=" .. val + end + return "return {" .. table.concat(parts, ",") .. "}" +end + +dofile("lib/serialization.lua") + +local failures = 0 +local function check(name, cond) + if cond then + print("ok - " .. name) + else + failures = failures + 1 + print("FAIL - " .. name) + end +end + +-- ─── 1. Oversized payload is rejected before any decode work ────────────────── +decode_calls = 0 +local big = string.rep("A", MP.UTILS.MAX_ENCODED_BYTES + 1) +local res, err = MP.UTILS.str_decode_and_unpack(big) +check("oversized payload returns nil", res == nil) +check("oversized payload reports 'too large'", type(err) == "string" and err:find("too large") ~= nil) +check("oversized payload never reached love.data.decode", decode_calls == 0) + +-- ─── 2. Non-string payload is rejected ─────────────────────────────────────── +local res2 = MP.UTILS.str_decode_and_unpack({ not_a = "string" }) +check("non-string payload returns nil", res2 == nil) + +-- ─── 3. A normal small payload still round-trips ───────────────────────────── +local original = { name = "Blueprint", cost = 10 } +local encoded = MP.UTILS.str_pack_and_encode(original) +check("normal payload is under the cap", #encoded <= MP.UTILS.MAX_ENCODED_BYTES) +local decoded = MP.UTILS.str_decode_and_unpack(encoded) +check("normal payload round-trips", type(decoded) == "table" and decoded.name == "Blueprint" and decoded.cost == 10) + +if failures > 0 then + error(failures .. " check(s) failed") +end +print("\nAll serialization guard checks passed.") From c3a1cfce930791895e0813c7b47453eca5b2d969 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Fri, 26 Jun 2026 17:29:25 +0100 Subject: [PATCH 5/7] chore(mod): bump version to 0.4.3 for the replay-log pre-release build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Commit message generated by Claude Code (https://claude.com/claude-code) --- Multiplayer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Multiplayer.json b/Multiplayer.json index 09d60caf6..933a36261 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,7 +12,7 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.4.2", + "version": "0.4.3", "dependencies": [ "Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", From 52aec006a6669873056f93ed18633f45a3b49f99 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Fri, 26 Jun 2026 18:15:01 +0100 Subject: [PATCH 6/7] fix(replay): crash-proof area_enum (table index is nil on shop buy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit area_enum built a lookup table keyed by the CardArea globals, but several of them are nil depending on game state (G.pack_cards only exists while a booster is open, G.shop_* only in the shop). A table literal with a nil key throws 'table index is nil', so any buy/sell/use while one of those areas was absent crashed the game — reproducibly on the first shop purchase. Compare the area against each global directly instead (a live area vs a nil global is just false), which is crash-safe and returns the same enum. Commit message generated with Claude Code --- lib/card_utils.lua | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/lib/card_utils.lua b/lib/card_utils.lua index 9c2657be5..0adcaf883 100644 --- a/lib/card_utils.lua +++ b/lib/card_utils.lua @@ -60,16 +60,19 @@ MP.UTILS.AREA = { -- Map a live CardArea object to its stable AREA enum int (or nil if unknown). function MP.UTILS.area_enum(area) if not area or not G then return nil end - local lookup = { - [G.shop_jokers] = MP.UTILS.AREA.shop_jokers, - [G.shop_booster] = MP.UTILS.AREA.shop_booster, - [G.shop_vouchers] = MP.UTILS.AREA.shop_vouchers, - [G.jokers] = MP.UTILS.AREA.jokers, - [G.consumeables] = MP.UTILS.AREA.consumeables, - [G.hand] = MP.UTILS.AREA.hand, - [G.pack_cards] = MP.UTILS.AREA.pack_cards, - } - return lookup[area] + -- Compare directly instead of building a lookup table keyed by the CardArea + -- globals: several of them are nil depending on game state (G.pack_cards + -- only exists while a booster is open, G.shop_* only in the shop), and a + -- table literal with a nil key throws "table index is nil". Comparing a + -- live area against a nil global is simply false, so this is crash-safe. + if area == G.shop_jokers then return MP.UTILS.AREA.shop_jokers end + if area == G.shop_booster then return MP.UTILS.AREA.shop_booster end + if area == G.shop_vouchers then return MP.UTILS.AREA.shop_vouchers end + if area == G.jokers then return MP.UTILS.AREA.jokers end + if area == G.consumeables then return MP.UTILS.AREA.consumeables end + if area == G.hand then return MP.UTILS.AREA.hand end + if area == G.pack_cards then return MP.UTILS.AREA.pack_cards end + return nil end -- 1-based index of a card within its CardArea's card list. `area` defaults to From 8125f5d1699f53e58e5fdb7563ab3d126c771336 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Fri, 26 Jun 2026 18:31:46 +0100 Subject: [PATCH 7/7] feat(replay): stream carbon lines live to the server in batches While a game plays, MP.RLOG now batches its carbon lines and flushes them to the server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id generated in begin_run. So a crashed/abandoned game still leaves a partial record server-side; on a clean end, submit_log_hashes carries the same game id so the server swaps the live stream for the complete hashed package. Flushing falls back to the line-count trigger when love.timer is unavailable (tests), and no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua. Commit message generated with Claude Code --- lib/replay_log.lua | 68 ++++++++++++++++++++++-- networking/action_handlers.lua | 17 +++++- tests/test_rlog_stream.lua | 97 ++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 tests/test_rlog_stream.lua diff --git a/lib/replay_log.lua b/lib/replay_log.lua index e214ed4bc..2f6ad6453 100644 --- a/lib/replay_log.lua +++ b/lib/replay_log.lua @@ -43,6 +43,15 @@ RLOG._run_active = false RLOG._manifest = nil RLOG._force_active = false -- test hook: bypass the lobby gate +-- Live streaming: carbon lines are pushed to the server in batches as the game +-- plays, so a crashed/abandoned game still leaves a partial record server-side. +-- On a clean end the server swaps that partial for the full hashed package. +RLOG._game_id = nil -- per-game grouping key, generated in begin_run +RLOG._pending = {} -- carbon lines buffered since the last flush +RLOG._last_flush = 0 -- love.timer.getTime() of the last flush (0 if unavailable) +RLOG.STREAM_FLUSH_LINES = 25 -- flush once this many lines are buffered, or... +RLOG.STREAM_FLUSH_SECS = 2 -- ...this many seconds have passed since the last flush + ------------------------------------------------------------------------------- -- Gate ------------------------------------------------------------------------------- @@ -85,12 +94,50 @@ local function emit(msg) sendTraceMessage(msg, "MULTIPLAYER") end --- Emit a carbon-stream line: tee to the Lovely log AND accumulate it into the --- full block we ship to the server at end_run (so the server keeps the whole --- viewable/replayable action log, not just its hash). +-- Per-game grouping key for the live stream. Lets the server group a game's +-- streamed lines and delete them once the final package lands. +local function new_game_id(manifest) + local lobby = (manifest and manifest.lobby_code) or "nolobby" + local who = (manifest and manifest.player) or "?" + return string.format("%s-%s-%d-%d", tostring(lobby), tostring(who), os.time(), math.random(100000, 999999)) +end + +-- Best-effort wall clock for flush pacing; 0 when love.timer is unavailable +-- (e.g. under the headless test harness), in which case flushing falls back to +-- the line-count trigger plus the end-of-run flush. +local function stream_now() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return 0 +end + +-- Send any buffered carbon lines to the server as one batch. No-ops cleanly if +-- there's no transport yet (e.g. tests) -- the lines are still kept in the full +-- carbon block submitted at end_run. +function RLOG.flush() + if #RLOG._pending == 0 then return end + if not (RLOG._game_id and MP.ACTIONS and MP.ACTIONS.stream_log_lines) then return end + local batch = RLOG._pending + RLOG._pending = {} + RLOG._last_flush = stream_now() + MP.ACTIONS.stream_log_lines(RLOG._game_id, batch) +end + +-- Flush once the batch is big enough or enough time has elapsed since the last. +local function maybe_flush() + if #RLOG._pending >= RLOG.STREAM_FLUSH_LINES then + RLOG.flush() + elseif (stream_now() - RLOG._last_flush) >= RLOG.STREAM_FLUSH_SECS then + RLOG.flush() + end +end + +-- Emit a carbon-stream line: tee to the Lovely log, accumulate it into the full +-- block we ship to the server at end_run, AND queue it for live streaming. local function emit_carbon(msg) RLOG._carbon_full[#RLOG._carbon_full + 1] = msg + RLOG._pending[#RLOG._pending + 1] = msg sendTraceMessage(msg, "MULTIPLAYER") + maybe_flush() end ------------------------------------------------------------------------------- @@ -138,6 +185,12 @@ function RLOG.begin_run(manifest) RLOG._manifest = manifest RLOG._run_active = true + -- Open the live stream for this game: fresh id + empty batch buffer. + RLOG._game_id = new_game_id(manifest) + manifest.game_id = RLOG._game_id + RLOG._pending = {} + RLOG._last_flush = stream_now() + local json = require("json") emit_carbon(RLOG.CARBON_PREFIX .. " MANIFEST " .. json.encode(manifest)) end @@ -158,11 +211,16 @@ function RLOG.end_run(outcome) emit_carbon(string.format("%s CHK v1 carbon=%s human=%s bytes=%d", RLOG.CARBON_PREFIX, carbon_hash, human_hash, bytes)) + -- Push any remaining streamed lines (incl. END + CHK) before the final + -- package, so an oversized/rejected package still leaves a complete stream. + RLOG.flush() + if MP.ACTIONS and MP.ACTIONS.submit_log_hashes then -- The full carbon block (manifest + actions + END + CHK) so the server - -- keeps the complete viewable/replayable log, not just its hash. + -- keeps the complete viewable/replayable log, not just its hash. The + -- game_id lets the server drop this game's live stream in favour of it. local carbon_log = table.concat(RLOG._carbon_full, "\n") - MP.ACTIONS.submit_log_hashes(carbon_hash, human_hash, RLOG._manifest and RLOG._manifest.seed, carbon_log) + MP.ACTIONS.submit_log_hashes(carbon_hash, human_hash, RLOG._manifest and RLOG._manifest.seed, carbon_log, RLOG._game_id) end RLOG._run_active = false diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 56f7b2d0d..679e9a204 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -1315,16 +1315,29 @@ function MP.ACTIONS.sync_client() }) end +-- Live carbon-log stream: batches of "MP_RLOG: ..." lines pushed while the game +-- is in progress, keyed by game_id so the server can group them and drop them +-- once the final package below lands. See lib/replay_log.lua (MP.RLOG). +function MP.ACTIONS.stream_log_lines(game_id, lines) + Client.send({ + action = "streamLogLines", + gameId = game_id, + lines = lines, + }) +end + -- End-of-game replay-log fingerprints. The server stores these (keyed by -- lobby + seed + game) so a presented log can later be re-hashed and compared --- without a line-by-line diff. See lib/replay_log.lua (MP.RLOG). -function MP.ACTIONS.submit_log_hashes(carbon, human, seed, log) +-- without a line-by-line diff, and uses game_id to delete the live stream in +-- favour of this complete package. See lib/replay_log.lua (MP.RLOG). +function MP.ACTIONS.submit_log_hashes(carbon, human, seed, log, game_id) Client.send({ action = "submitLogHashes", carbon = carbon, human = human, seed = seed, log = log, + gameId = game_id, }) end diff --git a/tests/test_rlog_stream.lua b/tests/test_rlog_stream.lua new file mode 100644 index 000000000..6b681d56d --- /dev/null +++ b/tests/test_rlog_stream.lua @@ -0,0 +1,97 @@ +--[[ + Replay-log (MP.RLOG) live-stream test. + + Verifies the in-game streaming layer: carbon lines are flushed to the server + in batches under a stable per-game id, every carbon line is streamed (manifest + + actions + END + CHK), and the final submit_log_hashes carries the same + game id (so the server can swap the live stream for the complete package). + + Uses a tiny flush threshold to force several batches. love.timer is absent, so + flushing falls back to the line-count trigger plus the end-of-run flush. + + Run from the repo root: + lua tests/test_rlog_stream.lua +]] + +package.loaded["json"] = { + encode = function() + return "{}" + end, +} + +local traced = {} +function sendTraceMessage(msg) + traced[#traced + 1] = msg +end +function sendWarnMessage() end + +local streamed = {} -- flat list of every streamed line, in order +local stream_game_ids = {} -- game id seen on each batch +local submitted +MP = { + LOBBY = { code = "TEST" }, + ACTIONS = { + stream_log_lines = function(game_id, lines) + stream_game_ids[#stream_game_ids + 1] = game_id + for _, l in ipairs(lines) do + streamed[#streamed + 1] = l + end + end, + submit_log_hashes = function(carbon, human, seed, log, game_id) + submitted = { carbon = carbon, human = human, seed = seed, log = log, game_id = game_id } + end, + }, + UTILS = { + joker_hash = function(s) + local a, b = 1, 0 + for i = 1, #s do + a = (a + s:byte(i)) % 65521 + b = (b + a) % 65521 + end + return string.format("%08x", b * 65536 + a) + end, + }, +} + +dofile("lib/replay_log.lua") +local RLOG = assert(MP.RLOG, "MP.RLOG not defined after load") +RLOG.STREAM_FLUSH_LINES = 2 -- force a flush every couple of lines +RLOG.STREAM_FLUSH_SECS = 1e9 -- disable the time-based trigger for the test + +RLOG.begin_run({ seed = "S", ruleset = "r", gamemode = "g", deck = "d", stake = 1 }) +RLOG.record("buy", { 1, 1 }, "action:boughtCardFromShop,card:X,cost:1") +RLOG.record("sell", { 4, 2 }, "action:soldCard,card:Y") +RLOG.record("reroll", nil, "action:rerollShop,cost:5") +RLOG.end_run({ result = "stop" }) + +-- ── Game id: present, stable across batches, matches the final submit ──────── +assert(#stream_game_ids > 0, "no live batches were streamed") +local gid = stream_game_ids[1] +assert(type(gid) == "string" and #gid > 0, "game id missing/empty") +for _, g in ipairs(stream_game_ids) do + assert(g == gid, "game id changed between batches") +end +assert(submitted, "submit_log_hashes was not called") +assert(submitted.game_id == gid, "final submit did not carry the streamed game id") + +-- ── Batching actually happened (threshold of 2 ⇒ multiple flushes) ─────────── +assert(#stream_game_ids >= 2, "expected multiple batches with flush-lines=2") + +-- ── Every carbon line was streamed, in order (manifest + actions + END + CHK) ─ +local carbon_traced = {} +for _, m in ipairs(traced) do + if m:match("^MP_RLOG:") then + carbon_traced[#carbon_traced + 1] = m + end +end +assert( + #streamed == #carbon_traced, + string.format("streamed %d lines but carbon block has %d", #streamed, #carbon_traced) +) +for i = 1, #carbon_traced do + assert(streamed[i] == carbon_traced[i], "streamed line mismatch at index " .. i) +end +assert(carbon_traced[1]:match("^MP_RLOG: MANIFEST "), "first streamed line must be the manifest") +assert(carbon_traced[#carbon_traced]:match("^MP_RLOG: CHK "), "last streamed line must be the CHK trailer") + +print("test_rlog_stream: OK")