Skip to content

Access-exact APU port timing, per-clock S-DSP, hardware DMA alignment - #241

Merged
mstan merged 1 commit into
RetroPortingToolKit:mainfrom
r-bin:spc-access-cycles
Nov 9, 2026
Merged

mstan merged 1 commit into
RetroPortingToolKit:mainfrom
r-bin:spc-access-cycles

Conversation

@r-bin

@r-bin r-bin commented Nov 2, 2026 •

Copy link
Copy Markdown

hey, I'm new here, so feel free to point out problems in the PR.

My goal was a minimal invasive and generic change to allow SoE to sync with MesenCE (cycle accurate).

The main complaint why this is required was that accessing the sound processor took either too long or not long enough.

The SPC700 handles all of an opcode's bus accesses on its first cycle, and the CPU and DMA are charged per block. So the timing of $2140–$2143 handshakes, timer reads and $420B drifts away from hardware. In Secret of Evermore, WRAM diverges from MesenCE 2.2.1 at NMI 319.

What changes:

  • SPC700 port, timer and $F3 accesses happen on their own cycle.

  • CPU port accesses sync the SPC up to their exact clock.

  • The S-DSP runs per clock.

  • DMA start and end are aligned as on hardware.

  • The $2137 latch is gated by WRIO, and $4212 H-blank is taken at the read's own clock.

Each change has a SNESRECOMP_*=0 switch to turn it off.

Result, all interpreted: WRAM matches MesenCE at every NMI of a 9,227-NMI new-game replay, and through NMI 5997 of the attract replay. Before, both stopped matching at NMI 319. The original implementation retained the old savestate layout; the maintainer repairs below update that layout to include the new timing state. The C tests pass.

Limits: the exact per-access clocks apply only in the interpreter, and I haven't measured the CPU cost of the per-clock DSP.

Maintainer validation and repairs (2026-11-03)

Tested head: 3147cba94a9a7501478c102ab5da500e7ba365ed.

  • Fixed current-build save/load: serialize the per-clock DSP phase, KON/rate/END pipeline, APU timing debt and SPC access cursor. Runtime states now use version 11 and reject older layouts. Invalid clock indices are rejected before changing live state.

  • Restored existing DAC voice-mask controls for the per-clock DSP, including echo, without changing canonical synthesis state or APU RAM.

  • Added audible BRR snapshot/continuation checks at all 32 DSP phases, both timing models, muted dry/echo output, canonical-state equivalence and invalid-state transaction coverage.

  • Python suite: 877 passed, 2 skipped. Existing mixer checks pass all 256 voice masks; focused snapshot/producer/capture tests also pass. The final follow-up commit only makes a Python import explicit.

Combined regression passed on ed0334b78150d325cd10750725c17157cb668c57: 880 Python tests passed (2 skipped), seven production PPU harnesses passed, and MMX, ALttP, SMW and Super Metroid passed representative gameplay, current-build save/load and matched runtime checks. The owner cleared the built games on 2026-11-03 (MMX okay, then all pass), completing this batch's regression/merge handoff. Runtime samples showed no demonstrated per-game regression; shared-host variation is not an optimization claim. The separate request for a reproducible Secret of Evermore replay recipe remains unresolved, so this PR stays open pending author evidence. The original interpreter replay result above remains the author's reported evidence; it has not been independently replayed here.

Boundary repair during review (2026-11-04)

Current head: 3856deb1a66f4f238286071c162acc6ab4bdcff2. The queued-write boundary overrun raised in the new discussion also exists in main; its default-model repair is #258. This branch now drains already-due writes before testing the target, while retaining access-exact hold/cleanup behavior. The added boundary regression passes all four portable/native APU and instruction-start/access-exact combinations. All four focused DSP snapshot and producer-pacing checks pass. The earlier full-suite/built-game evidence above remains identified with its original tested heads; it is not relabeled as a new SoE replay or hardware-input-delay qualification. The author replay recipe remains the outstanding acceptance item.

Strict C11 portability follow-up (2026-11-04)

Current head: aa2f09fa416f8d1a43e0dddc587e03953e1e67c2. Approving the previously waiting Linux CI exposed the new snapshot fixture compiling audio_trace.c in strict C11 without visible POSIX clock declarations. Reproduced the exact failure locally under native Ubuntu/GCC13.3, then exposed the POSIX declarations before system headers on non-Windows builds. The identical strict-C11 compile and all DSP snapshot cases now pass on Linux; all four focused Windows checks also pass. Fresh full CI is approved/running; no new full-suite pass is claimed yet for this follow-up. This does not supply the outstanding SoE replay recipe.

Digest stream boundary repair and fresh full CI (2026-11-04)

Current head: 810d438a5a6af0cb01f989d1f5d8c9e6f1dd0d9d. The full Linux C gate exposed the APU digest ring exclusion still using the pre-debt stream offset: serializing timer/DSP advance debt moved the ring four bytes and caused the last FIR field to be excluded. The skip offsets now include both debt blocks. Regression coverage also verifies every timer debt, DSP debt, KON/rate/END pipeline and clocked voice continuation state is included while all host ring data/cursors remain excluded.

The Linux negative control fails at the original final-FIR-field assertion; the repaired digest harness passes on native Linux and Windows, including both DSP timing models. Full Native analyzer CI on this exact head passes Linux, Windows and macOS, including the Linux Python, C runtime and optional content-pack gates. The prior built-game approval remains attached to its original tested head; this is not a new SoE replay claim.

The author's reproducible Secret of Evermore replay recipe remains required, so this PR remains open. TechnicallyComputers is assigned for the netplay digest change.

@mstan mstan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I pushed two objective repairs to this branch: complete current-build timing snapshots (RTLS v11, with invalid-index preflight) and restoration of existing DAC voice-mask/echo controls on the clocked DSP. The new regression runs audible BRR continuation at every DSP phase; the full Python suite passes 877 tests with 2 skips. The PR body records the exact scope. Representative combined built-game gameplay/save-load/runtime checks are still underway, so this is not an approval yet.

For the main SoE correctness result, please add the reproducible replay recipe: ROM revision/hash, consumer source/config head, MesenCE source/build identity and relevant settings, input replay (or deterministic input-generation instructions), and the commands/checkpoint comparison used for the 9,227-NMI / attract runs. I found the result in the description but no replay recipe in the changed files or PR conversation. Please keep ROMs and generated game C out of the repository. That will let us verify the intended timing contract independently; representative unrelated games alone cannot establish the SoE fix.

@pogoflow

pogoflow commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

This is close to what I had to fix for Secret of Mana in my fork, measured against bsnes and Mesen2. Two things from that work that may matter here:

  • apu_runToGuestCycle runs one SPC cycle past its target when an event is due exactly at portClock. A second port write in the same SPC cycle then maps behind the SPC and forces a rebase that never goes away. I could not tell from the description whether the access-exact path avoids this.
  • Mesen2 latches CPU port writes with an input delay: a write in the second half of an SPC cycle becomes visible one cycle later. Matching that was needed for my sound driver's handshake to line up.

Once this is merged I can run my Secret of Mana scripts on it: AOT and interpreter runs, plus state comparison against bsnes. I will report anything that differs.

@r-bin

r-bin commented Nov 5, 2026

Copy link
Copy Markdown
Author

thanks for the quick feedback. I managed to perfectly sync SoE to the MesenCE replays with a couple of changes to my snesrecomp fork, after a hard fight.

I'll start to clean up the repo now and prepare everything so it can be reproduced. Reporting back once I have everything and raised all PRs.

@r-bin

r-bin commented Nov 8, 2026 •

Copy link
Copy Markdown
Author

Sorry for the wait, I had to clean up my repos a lot to make them standalone. All of my evermore research gravitated to a mono repo, which was hard to work with.

  • I prepared 4 PRs in my fork of snesrecomp, and this is the first one (they are on extra branches and I'm not sure if it would make sense to fix everything at once or not)
  • Claude also had a look at the PRs of @pogoflow and they seem to be relevant as well, so maybe both games can be fixed at the same time.
  • let me know if I can further support you (and please also let me know when I use snesrecomp incorrectly or I can improve my recomp :D)

Repositories

Recipe

  • ROM: Secret of Evermore (USA), headerless, SHA-256 17c864a76d498feb6479eee8e7d6807b951c66225033228622bb66754baab1db.
  • MesenCE 2.2.1 release (20ba206c), default settings, headless: --testrunner --snes.ramPowerOnState=AllZeros --snes.disableFrameSkipping=true.
  • Input: MesenCE movies from power-on (input and settings only) in tools/compare/replays/: 01-no-inputs-1-loop.mmo (attract, 8,592 NMIs) and 02-new-game-to-flowers.mmo (23,756 NMIs). These replace the 9,227-NMI run, which started from a save state I can't publish.
  • Comparison: WRAM, VRAM, CGRAM and OAM hashed at every NMI from power-on; nothing copied in, nothing masked.
git clone --recursive https://github.com/r-bin/SecretOfEvermoreRecomp && cd SecretOfEvermoreRecomp
bash tools/regen.sh --rom /path/to/rom.sfc
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j
node tools/compare/compare.mts mesen recomp /path/to/rom.sfc --replay tools/compare/replays/01-no-inputs-1-loop.mmo --regions wram,vram,cgram,oam --verbose

Expected: in sync through counter $0100 = 8592. To turn a single part off, set its switch, e.g. SNESRECOMP_DMA_ALIGN=0 → wram differ at counter $0100 = 26. All switches and the first NMI each one parts at are in docs/pr/README.md and docs/pr/01-access-exact-timing.md.

@mstan mstan self-assigned this Nov 9, 2026
@mstan
mstan force-pushed the spc-access-cycles branch from 810d438 to c1f84f6 Compare November 9, 2026 17:47
@mstan
mstan merged commit 7925230 into RetroPortingToolKit:main Nov 9, 2026
@pogoflow

Copy link
Copy Markdown
Contributor

Follow-up on my note from the 4th, now that this is merged. (The overrun in apu_runToGuestCycle I mentioned there was fixed in #258.) I merged main at d8116fac into my Secret of Mana fork and ran my scripts with the access-exact model on (SNESRECOMP_SPC_ACCESS_CYCLES=1). My fork compiles with its own cycle-parity emitter, so this is your APU side on my CPU side, not your default setup end to end.

  • AOT vs. interpreter, same binary, compared cycle-exactly: identical in all four scenarios (281 to 461 checkpoints each; WRAM, VRAM, CGRAM, OAM, framebuffer, CPU and APU state). The model behaves the same on both execution paths.
  • Against bsnes after a common sync point (I copy the bsnes SPC state and the differing WRAM into the native run there, then compare every frame, APU state every tenth frame): three scenarios, and the outcome is the same with your model and with my fork's own one, which runs each SPC instruction in its last cycle and latches CPU port writes with Mesen2's input delay. All three pass with the same few short, self-healing timing phases; the phase numbers differ only slightly. The S-SMP timer phase stays within about 600 SPC cycles of bsnes (one driver tick is 4,608), and the driver is on the same tick at all but a few checkpoints (1 to 17 per scenario one tick off, once two). Apart from stack bytes, the two native runs differ only inside those timing phases (mostly the progress of a sound-data upload) and are identical again after each phase.
  • One difference, before the sync point: from power-on through the title screen and the load menu (about 1,500 frames), the native run with your model ends up further from bsnes. At the sync point 14 to 15 WRAM ranges outside the stack areas (319 to 333 bytes) differ, against 3 ranges (17 bytes) with my model. They are leftovers from the title and menu phase, mostly in 7E:17xx–18xx (partly menu variables), so the path through the menus diverges somewhere on the way. The sets are different, not nested: two of the three ranges that differ with my model match bsnes with yours. I have not narrowed it down yet, and my reference there is bsnes, not MesenCE, so it may be bsnes, or your model together with my CPU timing, rather than your model itself. I will follow up if I find the handshake where it diverges.

For now my fork keeps its own SPC model when cycle parity is on and uses yours otherwise. Per-clock S-DSP and DMA alignment stay on as you set them.

@pogoflow

Copy link
Copy Markdown
Contributor

Follow-up on the pre-sync difference from my last comment. Short version: I couldn't pin it on a specific handshake. It comes from two effects that don't point clearly at #241. Separately, there is one port-timing difference in the IPL upload that may be worth a look, and it lines up with the input delay I mentioned on the 4th.

Setup: WRAM and APU state every frame from power-on, native with both SPC models on the same CPU side, against bsnes (Mesen2 2.1.1 only for a few WRAM ranges; I have no MesenCE runs for SoM).

  1. Title screen, not menus. The extra ranges are title-screen state, not menu state as I guessed before. During the title screen both native runs are mostly one frame ahead of bsnes, so that offset comes from my setup. With my model the run falls back into step around Start. With yours it stays ahead longer and makes one more update before the title screen stops, and that is what's left at the sync point. There is no difference in port traffic in that window (one CPU write, same frame on all three sides), so I can't tie it to the port timing. Cause still open.

  2. Sync point one frame early. Before the next upload the CPU waits while the sound driver counts down on timer 0. With your model the countdown ends a frame earlier. The timer phase is set again whenever the driver reprograms timer 0, and which model lands closer to bsnes changes from one restart to the next. So I don't read this as a consistent bias in either model.

  3. IPL upload (boot, first block), in bytes per frame: bsnes 510.4, Mesen2 510.9, my model 520.5, yours 529.8, on the same CPU side. With SNESRECOMP_SPC_WRITE_BIAS=1 yours gives 519.3, about the same as mine; SNESRECOMP_SPC_READ_BIAS 1 or 2 barely moves it. So in this handshake the difference between the two models is a write delay of about one SPC cycle, which is what Mesen's input delay for CPU port writes would add. The rest of the gap to bsnes and Mesen2 is shared by both models, so it's on my side. If SoE's IPL upload already matches MesenCE in your setup, this may just be how your model meets my CPU timing.

I can't publish the SoM setup, but if you want a switch or one of your follow-up branches checked on SoM, I can run it here and report back.

@pogoflow

pogoflow commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

@r-bin, a follow-up on your resume branches. (Edited: I first wrote this against aot-bus-exact/aot-resume at 09cf15ec, then saw aot-resume-main (e8c37cc1), which puts resume on region-paced code. I dropped the notes that only applied to bus_exact.py.)

My fork has the same goal for Secret of Mana: compiled code and the interpreter at the same clock at every instruction. I haven't run the game on aot-resume-main yet.

No rush on any of this. These are just notes in case they help, so skip whatever doesn't fit your plan.

  1. IRQ deadline. Region-paced blocks already call cpu_aot_instruction_boundary() before their deadline poll, so with my open Yield region-paced code at the next H/V IRQ latch #276 compiled code also yields at the next H/V IRQ latch, as the interpreter does. Without it, a raster IRQ that latches inside compiled code is taken at the frame-end deadline instead; in my game that moved a mode-7 split. Interpreted gaps nested inside paced code still poll neither deadline (noted in Yield region-paced code at the next H/V IRQ latch #276).

  2. Interrupt handlers under beam_frame_driver. Your compiled RTI goes on at the popped PC while the scheduler owns the run, but the beam driver still delivers interrupts through the bounded interp_bridge_run_interrupt, and my game runs a whole loop inside its handler. If a scheduler-slice mode for the beam driver (opt-in would be enough) is part of your plan, I could drop my fork's resumable handlers.

Once you open the PR, I'm happy to run my game on it and post the result here, whenever it suits you.

@r-bin

r-bin commented Nov 11, 2026 •

Copy link
Copy Markdown
Author

@pogoflow

with Mesen2's input delay.

this slightly concerns me, since I don't know the differences between the emulators. Do you think we optimize into different directions? I feel like there should either be only one primarily supported emulator, or all of them should be support (however much work that might be)

The only reason I chose mesenCE is because they are open source, cycle accurate (to my knowledge) and easily moddable.

If there is a officially supported emulator I'll gladly port all of my work to that emulator.

@pogoflow

Copy link
Copy Markdown
Contributor

@r-bin, I don't think we are, at least not on the APU side. MesenCE is a fork of Mesen2, and the input delay I meant is the same code in both: Spc::CpuWriteRegister() applies a CPU port write at once if it lands in the first half of the current SPC cycle; otherwise it sets _pendingCpuRegUpdate, and ProcessCycle() copies the new value in after the next SPC cycle. Mesen2 added that in December 2024, and MesenCE 2.2.1 has it unchanged. So "Mesen2's input delay" is MesenCE's as well; I should have written "Mesen's".

I used bsnes as the reference for full-state comparisons (WRAM and APU state every frame) only because that's what my tooling was built around, not because I prefer its rules. Your question was a good reason to change that, so I've moved my checks over: the per-frame state comparisons and all my other emulator runs now use MesenCE 2.2.1, and bsnes is no longer part of my setup. The results matched what I had against bsnes. The one place I know of where we did follow different emulators is brightness: my #257 rounds like bsnes, and your #281 adds MesenCE's truncation as an option, which seems like the right way to handle it until someone settles what the hardware does.

Which emulator should be the reference is the maintainers' call. From my side there is now only one, MesenCE, which is also what your SPC timing is measured against, so I'd be glad to see it become the official one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants