Gameplay differential capture¶
scripts/frida/gameplay_diff_capture.js records the original executable using
raw capture format 22. The host finalizes each completed run into the same
formats used by the rewrite debugger:
- CDT container 2, schema 15
- CRD replay 16
- a sibling
.rng_evidence.jsondiagnostic report - a typed
.evidence.msgpack.zstnative-evidence sidecar
Recordings are disposable. Only this version tuple is supported; regenerate a capture after any owned format changes.
Attach through the host¶
The host is the normal entry point because it attaches, collects the JSONL, and finalizes it in one workflow:
uv run --with frida==17.15.4 python scripts/frida/gameplay_diff_capture_host.py \
--process crimsonland.exe \
--script scripts\frida\gameplay_diff_capture.js \
--output-dir C:\share\frida
just frida-gameplay-diff-capture wraps the same command. Pass host arguments
after --, for example:
Terminal gameplay transitions close the active run before the game-over or
quest-results screen. A clean stream may end immediately after run_end;
there is no session trailer to race process teardown. The host never appends or
repairs lifecycle rows. EOF with an active run is a hard recapture error.
Useful host options:
--raw-path <path>: override the JSONL path in both host and injected agent--finalize-only: finalize an existing JSONL without attaching; requires--raw-path--keep-raw: retain the producer-private JSONL after successful finalization
For a direct attach:
The default raw path is
C:\share\frida\gameplay_diff_capture.jsonl. Run the host afterwards with
--finalize-only --raw-path ... to produce replay-grade artifacts.
Lifecycle contract¶
The JSONL stream is ordered and typed:
- one
session_start - one or more completed
run_start->tick->run_endsequences - EOF immediately after any completed
run_end
Capture format, session identity, row order, mode/quest identity, tick counts,
and lifecycle counters must agree. Run-local tick_index and session-global
global_tick_index advance exactly one at a time. Unknown fields, capture
errors, an empty run, or a missing run_end invalidate the whole capture.
Finalization deliberately has no partial-run or salvage mode. A truncated capture lacks evidence and must not be made to look like a valid behavioral comparison.
Run start¶
The most recent session crt_srand seed is stale by the time a run begins:
menus and prior startup work may already have consumed draws. The capture
latches the real CRT state immediately before the run's first terrain draw,
counts every bootstrap draw, and records the exact state where bootstrap ends.
These are written as rng_state_before_bootstrap, rng_bootstrap_calls, and
rng_state_after_bootstrap.
The state-transition frame consumes a discarded shared-CRT draw at 0x0040cac7
after that setup window and before the first gameplay update. The agent records it as a
game_frame_rng_advance replay prelude operation. Finalization only validates
the captured operation against the exhaustive outside-RNG state chain; it never
invents a missing operation.
Finalization verifies that advancing the before-state by the captured call
count produces the after-state. It uses the before-state for ReplayHeader.seed and records
run_start_seed_source = "rng_state_before_bootstrap" in CDT metadata. It also copies
captured creature-pool residue to ReplayHeader.initial_creature_pool. Together
these make the generated CRD start from the observed native run boundary rather
than an inferred clean state.
run_start.settings also records the exact replay identity used for the run:
tick rate, retry count, hardcore flag, detail preset, violence flag, world size,
and the complete decoded game-status blob. Finalization does not supply fallback
values. It sets preserve_bugs=true because the producer is the original game.
Only Survival, Rush, and Quest runs are replay-grade. Demo, Typ-o, Tutorial, and unknown modes fail explicitly; their exact driving data is not captured by this format.
Tick contract¶
Every tick has the canonical schema 15 channels:
replay_stepcheckpointsim_stateentity_samplesrng_streamtiming_samples
replay_step is the replay-driving authority. Its wire order is dt, inputs,
prelude, postlude, commands. There is no sibling replay_inputs
transport field. The prelude contains named native frame-RNG advances and
between-tick perk operations that must run before simulation. The
postlude contains native perks_generate_choices calls observed late inside
gameplay_update_and_render, after the mode and simulation updates. Their RNG
rows remain in that tick's canonical rng_stream while replay executes the
postlude with tick RNG tracing still active. Terrain/bootstrap RNG is owned by
replay initialization and excluded from the outside-frame bag. Each remaining
outside draw carries a replay_operation_index: top-level frame draws must map
to a game_frame_rng_advance operation and use native caller 0x0040cac7, while
draws inside a captured perk operation map to that operation. Missing ownership,
an invalid caller for a frame operation, or a draw count that disagrees with a
frame operation invalidates the capture. commands is reserved for Typ-o input
and is therefore empty in supported Frida runs.
Perk choice snapshots always contain the seven native slots exactly, including
zeros and duplicates, plus the actual dirty bit. A pick records its exact
selection index in 0..6. A choice refresh inside the native tick is a separate
perk_menu_open postlude operation; a refresh between ticks is represented in
the next tick's prelude. An outside-tick selection pick likewise remains in the
next tick's prelude.
Input intent is captured before simulation rather than inferred from player
movement. sim_state.players separately records the resulting heading,
move_speed, move_phase, aim, and aim_heading, so a diff can distinguish
bad input capture from bad movement integration.
Each tick also requires exactly one gpur_enter timing row. Its f32 delta must
equal replay_step.dt, and its integer millisecond delta must equal the tick's
dt_ms_i32.
Canonical checkpoints deliberately contain only producer-neutral evidence.
Effective projectile hits exclude owner collisions, pickup counts are retained,
and sfx_count, SFX/hit heads, and death rows are zero or empty. Their complete
native values remain in the typed evidence sidecar. Quest elapsed time uses the
native quest_spawn_timeline; native time_played_ms and the summed replay
clock are retained beside it for diagnosis.
RNG evidence¶
The capture reads the real CRT RNG state from per-thread data instead of relying
on a software mirror. This exposes draws that bypass the crt_rand hook:
- canonical
rng_streamrows carry value, before/after state, call index, and required numeric caller for Frida captures - tick boundary samples record RNG state entering and leaving the gameplay update
- diagnostic bags count hooked draws outside gameplay-update windows
- every in-tick and between-tick LCG chain must be complete and contiguous
Any invalid transition, missing draw, dropped outside row, or unowned outside
draw rejects the capture. run_end.trailing_prelude retains operations observed
after the final gameplay update solely so finalization can prove ownership of
the tail RNG chain; those operations are not added to the replay. Finalization writes
gameplay_diff_capture.<mode>.run<k>.rng_evidence.json. The report summarizes
the exact bootstrap boundary and outside callers.
Caller attribution is diagnostic. If two traces have the same draw values and
state transitions but different caller labels, dbg diff reports
rng_caller_attribution_mismatch under channel_first_diagnostics; it does not
declare behavioral divergence.
Finalized output¶
Each completed run produces a matching artifact set:
- modes:
gameplay_diff_capture.<mode>.run<k>.cdt,.crd,.rng_evidence.json, and.evidence.msgpack.zst - quests:
gameplay_diff_capture.quest_<major>_<minor>.run<k>.cdt,.crd,.rng_evidence.json, and.evidence.msgpack.zst
The four files publish as one rollback-safe bundle. If any replacement fails, the previous complete bundle is restored and the raw JSONL is retained.
The rich sidecar is evidence format 2: one zstd frame containing little-endian
u32-length-prefixed MessagePack header, tick, and footer rows. Its header
binds the bundle to the session/module/pointer hashes and the raw/CDT/CRD
SHA256 values. Missing or unknown typed fields, trailing bytes, and concatenated
zstd frames are rejected.
Run these checks before investigating behavior:
uv run crimson dbg verify
uv run crimson dbg health <native.cdt>
uv run crimson dbg record <run.crd> --out <rewrite.cdt>
uv run crimson dbg health <rewrite.cdt>
uv run crimson dbg diff <native.cdt> <rewrite.cdt>
The diff reports the earliest divergent tick, the first mismatch for each
channel over the selected range, and f32 bit/ULP details for finite float
differences. Use dbg bisect and dbg focus to narrow the window, then
dbg tick, dbg entity, or dbg query for local evidence.
just frida-copy-share copies the entire share directory. just
frida-import-raw imports the CDT, CRD, RNG report, and typed evidence files
together so native diagnostics are not lost.
Capture fixtures¶
just capture-fixtures-import <captures_dir> imports current finalized pairs
into tests/fixtures/captures/. It preserves each full contiguous CDT, verifies
every trace block, the replay hash and tick range against the CRD sidecar, the
current Frida version, and the replay-aligned rng_state_before_bootstrap seed source.
It then writes versioned provenance to manifest.json. A stale or inconsistent
pair aborts the import instead of being skipped.
The importer and fixture tests accept only the current capture/CDT/CRD contract.
Old checked-in recordings should be deleted and replaced with a fresh format 22
capture. Fixture parity is a strict diff assertion; known mismatches are not
hidden behind a blanket xfail.