Skip to content

Commit 76d9f11

Browse files
committed
docs(ppu): research mid-scanline HDMA register timing + hi-res color-math, defer both
Confirmed a genuine, previously-undocumented off-by-one-line compositor bug against ares' per-pixel reference model. ppu/main.cpp's cycleRenderPixel() (the DAC/compositor output stage) only runs for hcounter [56, 1078] -- strictly before HDMA's per-line service point at hcounter 1104 (dot 276, HDMA_RUN_DOT). On real hardware an HDMA-driven per-line register write during line V therefore lands after line V has already finished drawing, and can only take effect starting line V+1 (the mechanism behind Air Strike Patrol's BG3 raster scroll). RustySNES's compositor instead renders line V at the very end of that line's own dot loop (Ppu::end_of_scanline, dot 340) -- *after* line V's own HDMA run at dot 276 -- so it observes and paints the value intended for V+1 across all of line V. Systemic, not confined to one title: any ROM that HDMA-drives a per-line scroll/window/CGRAM change is affected. Corrects two related overclaims this investigation surfaced: docs/ppu.md's "single-split-per-line effects work" claim (wrong -- even a single split lands one line early), and both that doc's and lib.rs's claim that the per-scanline compositor is unconditionally "bit-identical" to a per-dot renderer (only true when no mid-line-rendered register changes -- false in exactly this case). Not fixed this pass. The conceptually-correct fix (composite line V using pre-line-V-HDMA register state) touches the single hottest, most-tested code path in the engine -- all 29 currently-passing undisbeliever goldens, every commercial-screenshot test, playable_smoke -- and needs a dedicated committed test ROM plus a full --features test-roms re-verification to land safely, neither of which exist yet. Documented mechanism, root cause, and exactly what a future fix needs in docs/ppu.md's new "Mid-scanline/HDMA-driven register timing" section. Separately confirmed hi-res (Modes 5/6) color-math precision is blocked entirely on 512-wide hi-res output not existing yet, not a numeric-precision nuance to layer onto an existing feature. ares' DAC::run() (dac.cpp) shows hi-res is a dual-half-pixel trick: each PPU pixel clock emits two output columns by alternating between the "above" (normal, post-color-math) and "below" (subscreen re-blended) compositor results, not by computing 512 independently-color-math'd columns. Since rustysnes-ppu doesn't emit 512-wide output at all yet (Modes 5/6 currently render at 256-wide), there is no existing hi-res path to add color-math precision to -- documented as blocked on that larger feature landing first, in docs/ppu.md's new "Hi-res color-math precision" section. No .rs code behavior changed -- only lib.rs's module doc comment was corrected to match the finding. Verified: cargo fmt --all --check clean, cargo clippy --workspace --all-targets -- -D warnings clean, cargo test --workspace (326 passed), cargo test --workspace --features test-roms (339 passed, zero regressions as expected for a doc-only change). Updates to-dos/VERSION-PLAN.md's v0.5.0 bullet with both findings.
1 parent 826b380 commit 76d9f11

4 files changed

Lines changed: 166 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Added
1313

14+
- **Mid-scanline/HDMA-driven register timing + hi-res color-math precision: researched — `v0.5.0`
15+
"Fidelity" work.** Confirmed a genuine, previously-undocumented off-by-one-line compositor bug
16+
against ares' per-pixel reference model (`ppu/main.cpp`'s active-pixel rendering runs strictly
17+
*before* HDMA's per-line service point): RustySNES's end-of-line compositor applies a line `V`
18+
HDMA-driven register write to line `V` itself, when real hardware only ever observes it starting
19+
line `V+1` (the mechanism behind Air Strike Patrol's BG3 raster scroll and any similar
20+
HDMA-driven per-line effect). Corrected two related overclaims this same investigation surfaced
21+
(`docs/ppu.md`'s "single-split-per-line effects work" claim, and both that doc's and
22+
`crates/rustysnes-ppu/src/lib.rs`'s claim that the per-scanline compositor is unconditionally
23+
"bit-identical" to a per-dot renderer). Not fixed this pass — the change touches the hottest
24+
code path in the engine (every frame, all 29 currently-passing goldens) with no dedicated test
25+
ROM yet to verify a fix against; full mechanism and what a fix needs documented in
26+
`docs/ppu.md` §Mid-scanline/HDMA-driven register timing. Separately confirmed hi-res
27+
color-math precision (Bishoujo Janshi Suchie-Pai / Marvelous+SA-1) is blocked entirely on
28+
512-wide hi-res output not existing yet (ares' `DAC::run()` shows hi-res is a dual-half-pixel
29+
alternating-compositor-result trick, not a numeric-precision tweak) — a real feature gap, not
30+
this pass's scope; full mechanism in `docs/ppu.md` §Hi-res color-math precision. No `.rs` code
31+
changed beyond doc comments; full workspace + `--features test-roms` suites verified unaffected.
32+
1433
- **The "DMA/HDMA-collision crash quirk": researched and reclassified — `v0.5.0` "Fidelity"
1534
work.** The SNESdev errata page's DMA section bundles three distinct behaviors under this
1635
vague label: a version-1-5A22-only crash and a version-2-5A22-only silent-DMA-failure bug

‎crates/rustysnes-ppu/src/lib.rs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@
1818
//! H runs 0..=339 (340 wraps to a new line), V runs 0..=261 (NTSC) / 0..=311 (PAL). Active
1919
//! output is dots 22..=277 on lines 1..=224 (1..=239 overscan); `VBlank` asserts at V=225
2020
//! (V=240 overscan). The renderer is per-scanline (it composites a whole visible line at the
21-
//! line's end), which is bit-identical in the final framebuffer to a per-dot renderer and far
22-
//! simpler — the determinism contract only requires the *final frame* be reproducible.
21+
//! line's end), which is far simpler than a per-dot renderer and bit-identical to one *only when
22+
//! no register a line's rendering reads changes mid-line* — a per-line HDMA-driven register
23+
//! write (e.g. a raster scroll split) currently lands one scanline too early against real
24+
//! hardware, a confirmed, documented, not-yet-fixed gap (`docs/ppu.md` §Mid-scanline/HDMA-driven
25+
//! register timing).
2326
//!
2427
//! # Rendering note (clean-room)
2528
//!

‎docs/ppu.md‎

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,13 @@ The crate is a working dual-chip model. Public API the scheduler/bus call:
167167
`nmi_pending()`/`ack_nmi()`, `irq_pending()`/`ack_irq()`, `in_vblank()`/`in_hblank()`,
168168
`dot()`/`scanline()`, `frame_ready()`/`take_frame()`/`frame_count()`, `framebuffer() -> &[u16]`.
169169
- **Rendering model:** **per-scanline** — the whole visible line is composited at the line's
170-
end into a `256×239` 15-bit framebuffer. This is bit-identical in the *final frame* to a
171-
per-dot renderer (the determinism contract only requires the finished frame be reproducible)
172-
and far simpler. BG modes 0–7 tile fetch (2/4/8 bpp), per-mode priority tables, 16×16 tiles,
170+
end into a `256×239` 15-bit framebuffer. This is far simpler than a per-dot renderer, and
171+
bit-identical to one **only when no register a line's rendering reads is changed mid-line**
172+
(the determinism contract only requires the finished frame be reproducible, so this is a valid
173+
simplification *when the equivalence holds* — but it does NOT always hold: see "Mid-scanline/
174+
HDMA-driven register timing" below for a confirmed off-by-one-line compositor bug this
175+
end-of-line-sampling approach causes for HDMA-driven per-line register changes). BG modes 0–7
176+
tile fetch (2/4/8 bpp), per-mode priority tables, 16×16 tiles,
173177
mosaic (vertical+horizontal block), Mode 7 affine (matrix + center + wrap/flip from M7SEL,
174178
EXTBG high-bit priority), the 128-sprite OAM pipeline with the 32-sprite range / 34-tile time
175179
limits (reverse-order fetch → low index survives → STAT77 bits 6/7), color math (add/sub/half,
@@ -184,5 +188,121 @@ The crate is a working dual-chip model. Public API the scheduler/bus call:
184188
- Exact long-dot placement within the 1364-clock line under interlace transitions — resolve
185189
against the test ROMs (`ref-docs/2026-06-24-ppu.md` "Note on a flagged discrepancy").
186190
- Mid-scanline raster effects (HDMA palette/scroll splits) need the scheduler to drive register
187-
writes at the exact dot; the per-scanline compositor already samples end-of-line register
188-
state, so single-split-per-line effects work, but multi-split-per-line needs dot-resolution.
191+
writes at the exact dot. **Correction (researched this pass, superseding the prior claim
192+
here):** the per-scanline compositor does NOT correctly handle even single-split-per-line HDMA
193+
effects — see "Mid-scanline/HDMA-driven register timing" below for the confirmed off-by-one-line
194+
bug and why it isn't fixed in this pass.
195+
196+
## Mid-scanline/HDMA-driven register timing — researched, confirmed, deferred (v0.5.0)
197+
198+
**Status: a genuine off-by-one-line compositor bug is confirmed against ares' per-pixel reference
199+
model. Not fixed this pass — the fix touches the single hottest code path in the engine (every
200+
frame, all 29 currently-passing `undisbeliever` goldens, every commercial screenshot test) and
201+
needs dedicated empirical verification against the full golden suite, not a rushed change.**
202+
203+
### The bug (the "Air Strike Patrol BG3 scroll" case)
204+
205+
RustySNES's compositor renders scanline `V` at the very end of that line's own dot loop
206+
(`Ppu::end_of_scanline`, called when `h` wraps `DOTS_PER_LINE → 0`, i.e. after all 341 dots of
207+
line `V` — including line `V`'s own HDMA run — have already been processed by
208+
`Bus::advance_master`). HDMA's per-visible-line *run* fires at dot 276 (`HDMA_RUN_DOT`,
209+
`docs/scheduler.md` §DMA/HDMA bus-steal), which is *before* dot 340 where `end_of_scanline` runs.
210+
So when line `V`'s own HDMA write updates a scroll/CGRAM/window register, RustySNES's compositor
211+
— reading live register state at dot 340 — observes that NEW value and paints it across the
212+
*entirety* of line `V`'s already-rendered pixels.
213+
214+
Real hardware does not do this. ares' PPU is a genuine per-pixel renderer (`ref-proj/ares/ares/
215+
sfc/ppu/main.cpp`): `PPU::main()`'s `cycle<Cycle>()` unrolling only calls `cycleRenderPixel()`
216+
(the DAC/compositor output stage, `dac.cpp`'s `DAC::run()`) for `Cycle` in `[56, 1078]`
217+
(`main.cpp:217-219`) — i.e. every active pixel of line `V` is drawn using live register state by
218+
hcounter ~1078 (dot ~269.5), *before* HDMA services that same line at hcounter 1104 (dot 276,
219+
`ref-proj/ares/ares/sfc/cpu/timing.cpp`'s `hdmaRun()` trigger). A per-line HDMA write during line
220+
`V` therefore lands **after** line `V` has already finished drawing on real hardware — it can only
221+
take effect starting line `V+1`. This is the entire mechanism games like Air Strike Patrol rely on
222+
for a smooth per-line BG3 raster scroll: HDMA writes `S[V+1]` during line `V`'s late-active
223+
period, ready in time for line `V+1`'s draw.
224+
225+
RustySNES's current end-of-line compositor instead applies `S[V+1]` to line `V` itself — every
226+
HDMA-driven per-line register change lands **one scanline too early**. This is a real,
227+
previously-undocumented accuracy bug (the prior claim in this doc, that "the per-scanline
228+
compositor already samples end-of-line register state, so single-split-per-line effects work",
229+
was wrong — corrected above). It is a systemic bug, not confined to Air Strike Patrol; any title
230+
that HDMA-drives a per-line scroll/window/CGRAM change is affected. The `crates/rustysnes-ppu/
231+
src/lib.rs` module doc's claim that the per-scanline compositor is "bit-identical in the final
232+
framebuffer to a per-dot renderer" is likewise only true when no register HDMA-changes mid-line —
233+
false in exactly this case.
234+
235+
### Why it isn't fixed this pass
236+
237+
The conceptually correct fix is small in shape — composite line `V` using the register state as
238+
it stood *before* line `V`'s own HDMA run (dot 276), not after — but the safe way to land it
239+
(e.g. moving `Ppu::end_of_scanline`'s render trigger earlier, or snapshotting the relevant
240+
registers at a boundary the scheduler and PPU currently have no shared concept of) touches:
241+
242+
- The single hottest, most heavily-tested code path in the whole engine — every one of the 29
243+
currently-passing `undisbeliever` golden-framebuffer hashes (`docs/STATUS.md`'s accuracy
244+
dashboard), every `*_oncart`/commercial-screenshot test, and the `playable_smoke` gate all
245+
render through this exact function.
246+
- A cross-crate coordination gap: `rustysnes-ppu` has no visibility into `rustysnes-core`'s HDMA
247+
scheduling today (by design — the PPU only sees the narrow `VideoBus` trait,
248+
`docs/architecture.md` §2) — the fix needs a clean way to communicate "render now, before this
249+
line's HDMA" without leaking DMA/HDMA concerns into the PPU crate's dependency graph.
250+
- No dedicated committed test ROM demonstrating this specific per-line-scroll-split pattern
251+
exists yet in `tests/roms/` (an Air-Strike-Patrol-equivalent ROM, homebrew or a permissively
252+
licensed `undisbeliever`/Krom/PeterLemon test, would make the fix independently verifiable
253+
rather than validated only against the existing (possibly HDMA-timing-insensitive) goldens).
254+
255+
Landing an unverified change against this surface area is a real regression risk to a currently
256+
green suite — mirrors `docs/scheduler.md`'s DRAM-refresh and open-bus-via-HDMA-latch treatment:
257+
research and confirm the mechanism, but do not force an implementation through without the
258+
verification infrastructure to prove it's actually correct.
259+
260+
### What a future investigation/fix needs
261+
262+
1. A committed test ROM (or a hand-assembled 65C816 program in a new `#[test]`, mirroring the
263+
pattern already used for rewind/run-ahead's synthetic per-frame signal tests) that exercises
264+
exactly this case: HDMA-driven per-line BG scroll changing every line, verified against a
265+
known-correct per-line expected value sequence.
266+
2. A design for how the scheduler communicates "line `V`'s HDMA run has completed" to the PPU
267+
without giving the PPU direct DMA/HDMA knowledge — e.g. a new narrow `VideoBus` (or a new
268+
`Ppu` method the scheduler calls at the right dot) that triggers `end_of_scanline`'s render
269+
step early, at (or just before) `HDMA_RUN_DOT`, rather than at dot 340.
270+
3. A full re-run of `cargo test --workspace --features test-roms` (the complete golden/oracle
271+
suite, not a subset) after the change, checked line-by-line against the *reason* for any
272+
diff — since some or all of the 29 current goldens may shift by exactly one scanline's worth
273+
of register state for any ROM that already uses HDMA-driven per-line effects, and each such
274+
shift needs confirming as "now correct" rather than blindly re-baselined.
275+
276+
## Hi-res (Modes 5/6) color-math precision — researched, deferred (v0.5.0)
277+
278+
**Status: researched; blocked entirely on 512-px hi-res output not existing yet (a real feature
279+
gap, not a precision nuance to layer on top of an existing feature) — see "Implementation status"
280+
above ("hi-res Modes 5/6 render at 256-wide, the 512-px doubling is deferred").**
281+
282+
Real hardware's hi-res color-math trick (the mechanism Bishoujo Janshi Suchie-Pai / Marvelous+SA-1
283+
rely on for pseudo-transparency/anti-aliasing) is confirmed against ares' DAC (`ref-proj/ares/
284+
ares/sfc/ppu/dac.cpp`, `DAC::run()`): in hi-res, **each PPU pixel clock emits two real output
285+
columns** by alternating between two *different* compositor results, not by computing 512
286+
independently-color-math'd columns —
287+
288+
```
289+
*line++ = hires ? belowColor : aboveColor; // the "even" 512-wide column
290+
*line++ = aboveColor; // the "odd" 512-wide column (always the normal path)
291+
```
292+
293+
`aboveColor` is the normal (256-wide) main-screen-composited-with-color-math pixel; `belowColor`
294+
is the *sub*-screen pixel blended a second time against the fixed/subscreen color (`DAC::below`,
295+
`dac.cpp:43-80`) — i.e. hi-res mode doesn't add new *spatial* detail, it doubles the horizontal
296+
rate and interleaves "with color math" and "as if reading one pixel earlier, blended differently"
297+
outputs to fake a smoother 512-wide gradient. This is architecturally a real feature (dual-output
298+
DAC stage), not a numeric-precision tweak to the existing color-math formula.
299+
300+
Since `rustysnes-ppu` doesn't yet emit 512-wide output at all (Modes 5/6 currently render at the
301+
normal 256-wide resolution, per "Implementation status" above), there is no existing hi-res output
302+
path to add color-math precision *to* — the color-math mechanism described here can only be
303+
modeled once genuine dual-half-pixel hi-res output is built. That's a real feature addition (a new
304+
`DAC`-equivalent output stage emitting two columns per pixel clock, alternating `above`/`below`
305+
compositor results), not a small targeted fix, and is explicitly out of scope for this pass per
306+
the same regression-risk discipline as the mid-scanline item above. Tracked as: build 512-wide
307+
hi-res output first (a separate, larger v0.5.0/v0.6.0-scope item), then revisit hi-res color-math
308+
precision once that foundation exists.

‎to-dos/VERSION-PLAN.md‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,23 @@ AccuracyCoin-equivalent). See `to-dos/phase-6-accuracy-to-100/`.
159159
Super FX/GSU games' GP-DMA ROM→GSU-RAM transfers interact with the change. Not landed; see
160160
`docs/scheduler.md` §Open bus via DMA/HDMA for the full mechanism and what a future
161161
investigation needs (an access-level trace, mirroring `docs/audit/spc7110-boot-crash-2026-07-08.md`'s
162-
approach for the SPC7110 gap). Still open: true
163-
mid-scanline/mid-dot writes (the "Air Strike Patrol BG3 scroll" case — this un-defers Phase 2's
164-
flagged "mid-line raster deferred" gap), hi-res color-math precision (Bishoujo Janshi Suchie-Pai
165-
/ Marvelous+SA-1). **Researched and
162+
approach for the SPC7110 gap). **Researched, confirmed, deferred:** true mid-scanline/mid-dot
163+
writes (the "Air Strike Patrol BG3 scroll" case) — confirmed against ares' per-pixel reference
164+
model (`ppu/main.cpp`'s `cycleRenderPixel()` only runs for hcounter `[56, 1078]`, strictly
165+
*before* HDMA's per-line run at hcounter 1104) that RustySNES's end-of-line compositor has a
166+
genuine, systemic off-by-one-line bug: an HDMA-driven per-line register write during line `V`
167+
is meant to take effect starting line `V+1`, but the current compositor (reading live register
168+
state at dot 340, after line `V`'s own HDMA already ran) applies it to line `V` itself. This
169+
un-defers (confirms as real, not yet fixes) Phase 2's flagged "mid-line raster deferred" gap.
170+
Not fixed this pass — the fix touches the hottest code path in the engine (every frame, all 29
171+
goldens) with no dedicated test ROM yet to verify against; full mechanism, why it's deferred,
172+
and what a fix needs in `docs/ppu.md` §Mid-scanline/HDMA-driven register timing. **Researched,
173+
deferred (blocked on a larger feature, not a precision nuance):** hi-res color-math precision
174+
(Bishoujo Janshi Suchie-Pai / Marvelous+SA-1) — confirmed against ares' `DAC::run()` that hi-res
175+
is a dual-half-pixel output trick (alternating `above`/`below` compositor results at 2× the
176+
pixel rate), which RustySNES cannot model at all yet since Modes 5/6 don't emit 512-wide output
177+
in the first place (a real feature gap, not a numeric-precision tweak) — full mechanism in
178+
`docs/ppu.md` §Hi-res color-math precision. **Researched and
166179
reclassified:** the 65816 `$4203`/`$4206` overlapping-multiply/divide case (SNESdev's own
167180
errata documents this as producing genuinely *undefined* RDMPY/RDDIV output — no canonical
168181
"corrupted" value exists to port, and inventing one would violate `docs/adr/0004`'s

0 commit comments

Comments
 (0)