Skip to content

Beam: MesenCE's field and interrupt timing (short line, $4212 at the access, DRAM refresh at the beam, NMI recognition) - #284

Open
r-bin wants to merge 5 commits into
RetroPortingToolKit:mainfrom
r-bin:frame-rules-main
Open

r-bin wants to merge 5 commits into
RetroPortingToolKit:mainfrom
r-bin:frame-rules-main

Conversation

@r-bin

@r-bin r-bin commented Nov 11, 2026

Copy link
Copy Markdown

this is kind of part 2 of "why on earth does Evermore produce RNG with mid-frame memory" (I'd expect some minor merge conflicts with PR #278)
It used to be handled in src/soe_frame_driver.c, but that solution was very volatile. The recommended way for this to be merged was 3 small PRs that depended on each other, which I overruled for simplicity.

Summary

Five hardware timing rules that let a frame driver match MesenCE clock for clock. Each sits where its hardware lives, has its
own switch, and is on by default only with the master-clock APU timeline (RtlApuUseMasterClock, #241). Without that timeline
nothing changes.

 snes.c
-  every line 1364 clocks, every field 357,368
+  line 240 of every other non-interlaced field: 1360 clocks, no long dots   SNESRECOMP_SHORT_LINE
+    (Snes.fieldParity, toggled at V=0; savestate v12)
   $4212 read
-  bits 7, 0: from the beam's last sync
+  bits 7, 0: at the read's own clock, as bit 6 already is                   SNESRECOMP_HVBJOY_EXACT
 snes_refresh_charge
-  40 clocks per 1364 clocks of CPU time, anywhere
+  40 clocks per refresh point crossed, at H = 538 - (line start & 7)        SNESRECOMP_REFRESH_AT_BEAM
+  an APU port access behind a point sees the stall first
 snes_beam_frame_driver_run_frame
+  if SNESRECOMP_BEAM_EXACT: run_frame_exact (the old path is untouched)
+    CPU starts at master clock 186                                          SNESRECOMP_RESET_CLOCK
+    interrupt sequence: 8 cycles, 60 clocks, a refresh point stalls it
+    field NMI after the first instruction with a cycle from V=225 H=6       (snes/last_cycle.c)
+    NMI enabled inside vblank (RDNMI set): after the next instruction, same field
+    slices end at each refresh point; the beam follows the CPU after each
+    SnesBeamFrameHooks: frame_start, interrupt_entry, nmi_taken, field_nmi, slice_end

A host needs no timing code of its own for these: no beam alignment after a field, no walking the beam ahead for $4212, no
refresh top-ups. docs/FRAME_MODEL_HOSTS.md lists the switches.

Why

Each rule broke Secret of Evermore in a way a player or a replay sees; each touches code most SNES games contain.

Rule What went wrong in Evermore without it The pattern it affects in any game
Short line 2 clocks a field ahead of hardware (774 by field 378); the attract demo seeds its RNG from the H counter after three field-long DMAs and read $F6 for $F8: another demo from NMI 11986 RNG seeded from $2137/$213C; anything that counts on the field length over minutes
Late NMI the reset code writes $4200 = $81 at V=226; the NMI waited a field, so the NMI count was one behind from frame 5 (6 times in the first 4 minutes, at scene loads) enabling NMI after a forced-blank load, inside vblank
NMI recognition the idle loop CMP $0100 / BEQ was interrupted at the other instruction; the 52-clock phase carried into the next fields every main loop that waits for the NMI
Interrupt sequence every NMI handler ran 55-60 clocks early every NMI and IRQ handler; where a raster split lands
$4212 at the access the NMI handler's BIT $4212 / BNE auto-read wait ended up to 9 passes early; LDA $4212 / BMI left a pass late in the last 20 clocks before line 0 the vblank and auto-joypad waits in nearly every game
Refresh at the beam the sound upload's CMP $2140 / BNE polled one pass early about every 25 handshakes the standard SPC700 upload handshake
Reset clock the first instruction ran 186 clocks early, so every clock of the boot was off by that anything timed from power-on

The cost of not having them: a host that wants hardware timing has to rebuild them outside the framework, as we did (several
hundred lines that moved the beam after the fact), and that is fragile: walking the beam ahead and syncing back quietly moved
the field grid by 2 clocks, which our own alignment hid until this work removed it. Only Evermore is measured; for other games
the right column says where to look, not what we tested.

Evidence

  • Before: Secret of Evermore's host carried all five rules itself (moving the beam after each field, walking it 20 clocks
    ahead at vblank edges, topping up refresh). With that code removed and any one switch off, replay 01 (power-on, attract demo)
    parts from MesenCE 2.2.1:
    SNESRECOMP_SHORT_LINE=0       differs at NMI 60
    SNESRECOMP_RESET_CLOCK=0      differs at NMI 60
    SNESRECOMP_HVBJOY_EXACT=0     differs at NMI 180
    SNESRECOMP_REFRESH_AT_BEAM=0  differs at NMI 60
    
    After: host timing code removed, all switches at their defaults, WRAM, VRAM, CGRAM and OAM compared at every NMI from
    power-on:
    replay 01 (attract demo)       in sync, 8,592 of 8,592 NMIs (native and all-interpreted)
    replay 02 (new game, 6.5 min)  in sync, 23,756 of 23,756 NMIs
    
    Measured together with Return what the PPU fetches for OAM and CGRAM reads during rendering #278 (OAM/CGRAM reads during rendering), which replay 01 needs from NMI 5998.
  • Tests, no ROM:
    tests/dma/short_line_test.c           field lengths from reset 306,900 / 357,368 / 357,364 ..., interlace (on and off)
    tests/dma/hvbjoy_refresh_test.c       $4212 vblank and auto-read edges at the access, refresh points
    tests/dma/refresh_charge_test.c       one stall per point crossed, none for a teleport (on and off)
    tests/dma/last_cycle_test.c           last bus cycle: internal, I/O, joypad, WRAM, branches, stack
    tests/frame_driver/exact_rules_test.c scripted CPU on the real beam: NMI position, 60-clock sequence, late NMI
    
    pytest tests/v2 tests/test_snes_cycles.py tests/test_beam_events.py tests/test_beam_frame_state.py tests/snapshot_guard:
    645 passed (macOS). Same speed: 322 frames/s, 94.06 % native (318, 94.05 % before).

Merge Danger

Door: two-way

Every rule has a switch, and the default turns them on only with the master-clock APU timeline. One part does not revert by
switch: savestate v12 adds Snes.fieldParity, so a v12 state does not load in an older build (v11 states load here with a
fresh reset's parity).

Blast Radius: opt-in

Only hosts that call RtlApuUseMasterClock. For them, field lengths, $4212 and refresh timing change, and the frame driver
takes its exact path. Conflicts with #278 in snes_beam_at (a few lines); the resolution is ready, whichever merges second.
Checked on one game and on macOS only.

r-bin and others added 5 commits November 11, 2026 17:05
On NTSC without interlace, line 240 of every other field lasts 1360 master
clocks, not 1364, with no long dots: fields alternate 357,368 and 357,364
clocks (MesenCE from power-on; fullsnes). Snes.fieldParity toggles at each
wrap to V=0, false after reset, and every beam walk in snes.c takes the
line's length from snes_line_clocks: the advance, the clocks to a line or an
IRQ, the next IRQ clock, the $2137 latch and $4212 bit 6. The parity is
saved after the RDNMI latch (RTLS v12; v11 files load with reset's parity).

The beam frame driver starts the CPU at master clock 186, the 65816's reset
sequence, with the beam at 0.

SNESRECOMP_SHORT_LINE and SNESRECOMP_RESET_CLOCK switch them; both default
to on only with the master-clock APU timeline (RtlApuUseMasterClock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SNESRECOMP_HVBJOY_EXACT (default: on with the master-clock APU timeline)
reads vblank and the automatic joypad read's busy bit at the access's clock,
as bit 6 already is (SNESRECOMP_HBLANK_EXACT), so no host has to walk the
beam ahead of the CPU to show an edge early. The automatic read runs from
the first master clock 128 past a multiple of 256 and at least 3 after line
225 began, for 4352 clocks (17 x 256; MesenCE autoReadClockStart).

snes_line_clocks is public: a host that places events on the beam's lines
needs the short line too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SNESRECOMP_REFRESH_AT_BEAM (default: on with the master-clock APU timeline):
snes_refresh_charge stalls the CPU 40 master clocks per refresh point it
crossed since the last charge, each at H = 538 - (the line's start & 7) of
every line (MesenCE dramRefreshPosition), instead of 40 per 1364 clocks of
CPU time at no fixed place. snes_next_refresh gives the next point. An APU
port access whose instruction crosses a point sees the stall first, after
any host access-clock hook.

Test: tests/dma/hvbjoy_refresh_test.c, $4212 edges at the access and the
refresh points (run from tests/test_beam_events.py, next commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SNESRECOMP_BEAM_EXACT (default: on with the master-clock APU timeline) runs
snes_beam_frame_driver_run_frame on an exact path; the old path is kept
unchanged for every other host:

- the interrupt sequence costs 8 cycles and 60 master clocks, and a refresh
  point inside it stalls it;
- the field's NMI follows the first instruction that starts a cycle from
  V=225 H=6 (snes/last_cycle.c: the length of an instruction's last bus
  cycle), not the first to end past V=225;
- an NMI enabled inside vblank, RDNMI still set, fires after the next
  instruction in that field, not a field later;
- CPU slices end at each DRAM refresh point and the beam follows the CPU
  after each one.

SnesBeamFrameHooks lets a host add its own work (field start, interrupt
entry, NMI taken, a field NMI it takes itself, slice end);
snes_beam_frame_driver_deadlines gives the running slice's deadlines.
docs/FRAME_MODEL_HOSTS.md lists all five switches.

Tests: tests/frame_driver/exact_rules_test.c (a scripted CPU on the real
beam), tests/dma/last_cycle_test.c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/dma/refresh_charge_test.c runs snes_refresh_charge, cut out of
common_cpu_infra.c: a stall exactly when a refresh point is crossed, two in
one charge, none for a teleport; with SNESRECOMP_REFRESH_AT_BEAM=0 the old
40 per 1364 clocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant