Skip to content

chore(info): savestate_features 2 -> 3, with the test that backs it - #327

Merged
JoeMatt merged 3 commits into
developfrom
chore/savestate-features-deterministic
Aug 6, 2026
Merged

chore(info): savestate_features 2 -> 3, with the test that backs it#327
JoeMatt merged 3 commits into
developfrom
chore/savestate-features-deterministic

Conversation

@JoeMatt

@JoeMatt JoeMatt commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Spotted while updating the libretro/docs page: dist/info/ claimed savestate_features = "2" ("serialized") while retro_load_game reports zero serialization quirks and its comment claims run-ahead. Zero quirks is the level-3 ("deterministic") assertion, so the two disagreed. This makes them agree — but verifies the claim first rather than trusting the comment.

The test

test/tools/test_runahead_determinism.c reproduces RetroArch's run-ahead loop directly: warm up, serialize, run a window while digesting every frame's video and audio, retro_unserialize back, replay the same window with the same (empty) input — then roll back and replay a third time.

That third pass is the load-bearing one. Pass 1 is the only pass reached without an intervening retro_unserialize, so anything the load path fails to restore shows up as a pass-1-vs-pass-2 difference and nowhere else. Run-ahead rolls back on every frame, so it lives entirely in the post-rollback regime that pass 2 vs pass 3 measures.

Results

Identical on Iron Soldier, Skyhammer and Kasumi Ninja (240 warmup / 120 window). yarc.j64 passes all four.

PASS  video_replay_identical     120/120 frames bit-identical
FAIL  audio_replay_identical     1 frame differs
PASS  state_reconverges          2490368 bytes, byte-identical
PASS  repeated_rollback_agrees   pass 2 == pass 3, video and audio

The single failure is the first frame after the first rollback, off by ~0.05% RMS (Iron Soldier: 2079.86 vs 2080.96) with an identical sample count. Frames 1..119 match bit for bit, and successive rollbacks agree completely including that frame.

So the level-3 claim holds for everything run-ahead actually depends on, and there is a real but bounded gap: some audio state is re-derived rather than restored by the load path.

What is already ruled out

The I2S ring buffer, despite being absent from DACStateSave. DACPrepareFrame reseeds i2sWritePos/i2sWriteCount to 2 every frame, and DSPSampleCallback clamps its read index to i2sWriteCount — so no sample from a previous frame can ever be read. The culprit is elsewhere in the DSP/DAC path.

Wiring

The test gets its own make runahead-determinism target and is deliberately not in make test while that assertion fails — a permanently-red suite entry is worse than no entry. Wire it in once the gap is closed.

make runahead-determinism RUNAHEAD_ROM="path/to/game.j64"

No core source is touched by this PR — only the .info value, the Makefile target, and the new test.

The .info claimed "serialized" (2) while retro_load_game reports zero
serialization quirks and its comment claims run-ahead. Level 3
("deterministic") is what zero quirks actually asserts, so the two now
agree.

Rather than take the code comment on trust, add
test/tools/test_runahead_determinism.c, which reproduces RetroArch's
run-ahead loop directly: warm up, serialize, run a window while
digesting every frame's video and audio, retro_unserialize back, and
replay the same window with the same (empty) input. It asserts four
things -- video digests match, audio digests match, the state
re-converges byte-for-byte, and two successive rollbacks agree with
each other.

Measured on Iron Soldier, Skyhammer and Kasumi Ninja (240 warmup / 120
window), all three agree:

  PASS  video_replay_identical     120/120 frames bit-identical
  FAIL  audio_replay_identical     1 frame differs
  PASS  state_reconverges          2490368 bytes, byte-identical
  PASS  repeated_rollback_agrees   pass 2 == pass 3, video and audio

The one failure is the first frame after the first rollback, off by
~0.05% RMS (Iron Soldier: 2079.86 vs 2080.96) with an identical sample
count; frames 1..119 match bit for bit. Successive rollbacks agree
completely including that frame, which is the regime run-ahead actually
runs in -- it rolls back every frame, so it never sees the transition.
yarc.j64 passes all four.

So some audio state is re-derived rather than restored by the load
path. The I2S ring buffer is ruled out: DACPrepareFrame reseeds
writePos/writeCount to 2 every frame and DSPSampleCallback clamps its
read index to i2sWriteCount, so no sample from a previous frame can be
read. The culprit is elsewhere in the DSP/DAC path.

The test is deliberately NOT in `make test` while that assertion fails;
it gets its own `make runahead-determinism` target. Wire it into the
suite once the gap is closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 6, 2026 01:03
@github-actions github-actions Bot added tests test harnesses, regression baselines build Makefile / Makefile.common / scripts/ labels Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Regression: macos-arm64

Regression Test Results

ROM Status Details Diff
jagniccc ✅ PASS 0 pixels differ -
yarc ✅ PASS 0 pixels differ -
jagniccc (determinism) ✅ PASS identical across runs -
yarc (determinism) ✅ PASS identical across runs -
jagniccc (frameskip) ✅ PASS skip=0 matches skip=3 -
yarc (frameskip) ✅ PASS skip=0 matches skip=3 -
jagniccc (save state) ✅ PASS round-trip matches -
yarc (save state) ✅ PASS round-trip matches -
jagniccc (rewind) ✅ PASS rewind matches -
yarc (rewind) ✅ PASS rewind matches -

Platform: Darwin arm64

Updated by CI at 2026-08-06T02:11:58.851Z

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Regression: linux-arm64

Regression Test Results

ROM Status Details Diff
jagniccc ✅ PASS 0 pixels differ -
yarc ✅ PASS 0 pixels differ -
jagniccc (determinism) ✅ PASS identical across runs -
yarc (determinism) ✅ PASS identical across runs -
jagniccc (frameskip) ✅ PASS skip=0 matches skip=3 -
yarc (frameskip) ✅ PASS skip=0 matches skip=3 -
jagniccc (save state) ✅ PASS round-trip matches -
yarc (save state) ✅ PASS round-trip matches -
jagniccc (rewind) ✅ PASS rewind matches -
yarc (rewind) ✅ PASS rewind matches -

Platform: Linux aarch64

Updated by CI at 2026-08-06T02:11:50.417Z

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Regression: linux-x64

Regression Test Results

ROM Status Details Diff
jagniccc ✅ PASS 0 pixels differ -
yarc ✅ PASS 0 pixels differ -
jagniccc (determinism) ✅ PASS identical across runs -
yarc (determinism) ✅ PASS identical across runs -
jagniccc (frameskip) ✅ PASS skip=0 matches skip=3 -
yarc (frameskip) ✅ PASS skip=0 matches skip=3 -
jagniccc (save state) ✅ PASS round-trip matches -
yarc (save state) ✅ PASS round-trip matches -
jagniccc (rewind) ✅ PASS rewind matches -
yarc (rewind) ✅ PASS rewind matches -

Platform: Linux x86_64

Updated by CI at 2026-08-06T02:11:47.788Z

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the core metadata to claim deterministic save states (savestate_features = "3") and adds a new diagnostic test + Makefile target intended to validate run-ahead style determinism via repeated serialize/unserialize replays.

Changes:

  • Add test/tools/test_runahead_determinism.c to replay windows across rollbacks and compare per-frame video/audio fingerprints.
  • Add make runahead-determinism target to build/run the new determinism tool.
  • Update dist/info/virtualjaguar_libretro.info to savestate_features = "3".

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated 5 comments.

File Description
test/tools/test_runahead_determinism.c New determinism test tool that mimics RetroArch run-ahead rollback/replay and compares frame digests.
Makefile Adds a runahead-determinism phony target and build/run recipe for the new tool.
dist/info/virtualjaguar_libretro.info Updates advertised savestate feature level from 2 to 3.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +111 to +112
/* Raw per-frame sample count and RMS, kept alongside the digests so a
* failure can say WHAT differed, not just that something did. */
Comment on lines +420 to +424
if (cap.count != pass1_count) {
first_video_diff = 0;
} else {
for (i = 0; i < pass1_count; i++) {
if (cap.video[i] != pass1_video[i] && first_video_diff < 0)
Comment on lines +296 to +304
char state_a[] = "/tmp/vj_runahead_a.state";
char state_b[] = "/tmp/vj_runahead_b.state";
char state_c[] = "/tmp/vj_runahead_c.state";
uint8_t *blob_b = NULL, *blob_c = NULL;
size_t len_b = 0, len_c = 0;
int state_stable = 0;
char detail_v[256], detail_a[256], detail_s[256], detail_r[256];
int failed = 0;

Comment on lines +386 to +387
/* 7. State C. */
harness_save_state(&cfg, state_c);
Comment thread Makefile
Comment on lines +1461 to +1467
# replays the same frames after retro_unserialize, and asserts the video and
# audio come back identical. This is the evidence behind
# `savestate_features = 3` in dist/info/, and behind reporting zero
# serialization quirks: run-ahead, rewind and netplay all assume a state is a
# complete snapshot.
#
# NOT part of `make test`: one assertion (audio_replay_identical) is a known
JoeMatt and others added 2 commits August 5, 2026 21:17
Narrows the known audio_replay_identical failure without fixing it yet.
dspFlagsRetireDelay/dspPreStoreBank looked like the answer -- audio-path
state that DSPStateLoad explicitly retires instead of restoring, which
produces exactly the observed pass-1-differs / pass-2==pass-3 shape. It
was implemented behind a state-version gate and measured: no change on
Iron Soldier, Skyhammer or Kasumi Ninja. The retire window is two
instruction slots wide, so a frame boundary practically never lands
inside it. Reverted rather than kept -- it changed the save-state format
for no measurable benefit.

Also ruled out and recorded: the event queue (all nine callbacks are in
event.c's registry, nothing is dropped by the pointer/id round trip),
jaguar_prng_state (init-only, never advances during retro_run),
dspgo_poll_count (pinned to 0 while audio is non-silent), and the
68K bus/scale counters (balanced or 1x-inert).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
The DAC register file lives in jagMemSpace at $F1A148-$F1A157, and no
STATE_SAVE_BUF covered it. retro_serialize saves jaguarMainRAM (the low
2 MB of jagMemSpace), tomRam8 and jerry_ram_8 -- and jerry_ram_8 is a
SEPARATE array in jerry.c, not the $F10000 window of jagMemSpace. So
LTXD, RTXD, SCLK, SMODE, LRXD, RRXD and SSTAT survived a load only by
accident, as whatever the previous run happened to leave behind.

DACPrepareFrame seeds the resampler's interpolation endpoints from
LTXD/RTXD every frame, so the first frame after retro_unserialize
started from the wrong endpoints and then converged as real DSP writes
landed. Measured as a ~0.05% RMS difference on exactly one frame
(2079.86 vs 2080.96 on Iron Soldier), identical sample count,
reproducible on Iron Soldier, Skyhammer and Kasumi Ninja.

Found by instrumenting DACPrepareFrame and diffing a natural run against
a post-rollback replay field by field: every DSP field matched (pc,
control, flags, acc, pipeline pointers, scoreboard, registers) and only
LTXD/RTXD differed. That diff is what turned a week's worth of plausible
suspects into a one-line answer -- see the test header for the four
candidates that looked guilty and were not.

Fields are appended to the DAC block behind
STATE_VERSION_DAC_REGISTERS (v8); older layouts consume nothing and keep
their existing behaviour. SCLK/SMODE feed the resample ratio, so the
loader re-derives it via DACUpdateSCLKRate once they hold restored
values.

test/tools/test_runahead_determinism now passes all four assertions on
Iron Soldier, Skyhammer, Kasumi Ninja and yarc, so it joins `make test`
(yarc unconditionally, Iron Soldier when the private corpus is present).

test_state_compat's deliberate DAC-block-size tripwire fired at 37 -> 51
bytes, exactly as designed; its expected size and layout comment are
updated. The v1/v2/v3 fixture offsets are unaffected -- the new fields
are appended, not interleaved.

Audio pair re-verified per CLAUDE.md: Iron Soldier 1 presence RMS 1175.7
(develop baseline ~1175, envelope 200-25000), Skyhammer clipping 0
saturation runs. Full suite exits 0 with no skipped checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the core Core orchestration (jaguar.c, event, memory, settings) label Aug 6, 2026
@JoeMatt

JoeMatt commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Root-caused and fixed — all four assertions now pass

The instrumented diff pinned it immediately. Dumping every input the frame's audio is derived from at DACPrepareFrame, then diffing a natural run against a post-rollback replay of the same frame:

  ltxd       pass1=FF57   pass2=FBFB
  rtxd       pass1=0173   pass2=F8A3

Every DSP field was identical — pc, control, flags, accumulator, modulo, remainder, bank, Z/N/C, pipeline pointers, scoreboard, r0/r1/r30/r31. Only LTXD/RTXD differed.

The bug

ltxd/rtxd point into jagMemSpace at $F1A148/$F1A14C (vjag_memory.c:177). retro_serialize saves three buffers: jaguarMainRAM (the low 2 MB of jagMemSpace), tomRam8, and jerry_ram_8 — and jerry_ram_8 is a separate array declared in jerry.c:187, not the $F10000 window of jagMemSpace.

So the entire DAC register file (LTXD, RTXD, SCLK, SMODE, LRXD, RRXD, SSTAT) was in no saved region. It survived a load only by accident, as whatever the previous run left behind.

DACPrepareFrame seeds the resampler's interpolation endpoints from LTXD/RTXD every frame, so the first frame after a rollback started from the wrong endpoints and converged once real DSP writes landed — exactly the observed one-frame, ~0.05% RMS, audio-only signature.

Fix

Serialize them in DACStateSave/DACStateLoad behind STATE_VERSION_DAC_REGISTERS (v8). Older layouts consume nothing and keep current behaviour. SCLK/SMODE feed the resample ratio, so the loader re-derives it via DACUpdateSCLKRate once they hold restored values.

Verification

All four assertions green on Iron Soldier, Skyhammer, Kasumi Ninja and yarc:

PASS  video_replay_identical     120/120 frames bit-identical
PASS  audio_replay_identical     120 audio batches compared
PASS  state_reconverges          2490368 bytes, byte-identical
PASS  repeated_rollback_agrees   pass 2 == pass 3

The test now joins make test — yarc unconditionally (in-tree, never skips) plus Iron Soldier when the private corpus is present.

test_state_compat's deliberate DAC-block-size tripwire fired at 37 → 51 bytes, exactly as it was written to. Its expected size and layout comment are updated; the v1/v2/v3 fixture offsets are unaffected since the fields are appended, not interleaved.

Audio pair re-verified per CLAUDE.md, since this touches dac.c: Iron Soldier 1 presence RMS 1175.7 (develop baseline ~1175, envelope 200–25000) and Skyhammer clipping 0 saturation runs. Full suite exits 0 with Skipped checks: none.

What this closes

savestate_features = 3 is now backed by a green test rather than by an argument about which property run-ahead depends on. Worth noting the bug was never run-ahead-specific — every ordinary save-state load restored stale DAC registers; it just produced one frame of slightly-wrong audio, which nobody would notice by ear. It took replaying the same frame twice to make it visible.

Four candidates that looked guilty and were not (the D_FLAGS retire delay was implemented, measured, and reverted) are recorded in the test header so nobody re-spends the time.

@JoeMatt JoeMatt self-assigned this Aug 6, 2026
@JoeMatt
JoeMatt merged commit a962e22 into develop Aug 6, 2026
32 checks passed
@JoeMatt
JoeMatt deleted the chore/savestate-features-deterministic branch August 6, 2026 02:23
JoeMatt added a commit that referenced this pull request Aug 14, 2026
* ci: add CodeRabbit config and a Kimi second-opinion PR reviewer

Copilot review is unavailable — every recent PR got "Copilot was unable to
review this pull request because the user who requested the review has reached
their quota limit", which is silence, not approval. Two independent reviewers so
one running dry is not the same as a PR going unreviewed.

.coderabbit.yaml — tuned to what actually breaks here rather than generic
defaults. Per-path instructions for the C89/MSVC rules (mid-block declarations
first), the big-endian GET/SET macro contract, "never trust a source comment for
hardware behaviour, cite the JTRM or a netlist", the savestate-field trap behind
#327/#400, the audio pair rule from PR #170, and the CI ephemeral-port flake
(#356). Generated and vendored trees are excluded from review: cpuemu.c alone is
~1.8 MB of machine-generated 68K dispatch, and the bios sources are bin2c hex
tables. The exemption list matches scripts/c89-lint.sh's skip_file.

.github/workflows/kimi-review.yml + .github/scripts/kimi_review.py — dependency
-free second opinion using the KIMI_KEY_* secrets. Runs on PRs into
develop/master and on "@Kimi review". Self-contained rather than a third-party
action so the auth shape stays visible in one file and there is no action
version to drift.

Assumption worth flagging: KIMI_KEY_VALUE is sent as the bearer token and
KIMI_KEY_ID as an identifying header. If the provider issues an AK/SK pair
needing a signed request, the job prints an explicit 401 diagnostic naming the
one function to change. Endpoint and model are overridable via the KIMI_API_BASE
and KIMI_MODEL repo variables without touching code.

The job fails SOFT throughout: an advisory review must never red-X a PR that
compiles and passes its tests.

Verified locally: python compiles, workflow YAML parses, and the diff filter was
exercised against two real commits -- it passes #442's five files through and
reduces the generated-matrix commit 3c4992d to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(kimi): point at the Kimi for Coding endpoint, not the Moonshot one

The first live run 401'd with "Invalid Authentication". The key was fine and
the protocol shape was fine -- the host was wrong.

Keys minted in the Kimi Code console (kimi.com/code/console) belong to the
"Kimi for Coding" SUBSCRIPTION, which is served from api.kimi.com/coding and is
a different product from the pay-per-token Moonshot platform at
api.moonshot.ai. A key from one 401s against the other. Defaults now:

  KIMI_API_BASE  https://api.kimi.com/coding/v1   (OpenAI-compatible base;
                 an Anthropic-compatible base exists at .../coding/)
  KIMI_MODEL     k3-256k

Both remain repo variables, so retargeting needs no code change. The 401 and
400/404 diagnostics now name the specific cause -- which host a key belongs to,
and the tier-dependent model list (k3, k3-256k, kimi-for-coding,
kimi-for-coding-highspeed) -- so the next misconfiguration is self-describing
instead of a bare status code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build Makefile / Makefile.common / scripts/ core Core orchestration (jaguar.c, event, memory, settings) tests test harnesses, regression baselines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants