Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **`E3.06` polls BOTH timers; a single end-of-interval read measured a phase, not a count.** The row
compares timer 2's rate against timer 0's over one interval. Timer 2 was already accumulated across
polls — that was the earlier fix for `TnOUT`'s four-bit ceiling — but timer 0 was still read
**exactly once**, at the end, on the reasoning that a handful of ticks is nowhere near its own wrap.
True about magnitude, and silent about phase: one read samples whatever sub-tick phase the previous
test left the timer in. `$F1`'s enable-raise resets a timer's stage-2 and stage-3 state (`E3.02`)
but nothing resets stage 0, so the reading was reproducible only while nothing before it disturbed
that phase.

It is not a hypothetical. Authoring a row that drives timer 2 hard (an SPC700 cycle-cost instrument)
broke `E3.06` **on the battery's second run and not its first** — the residue crosses the run
boundary. The band was not the problem and was not widened to admit it: **the row is right and its
instrument was one read short.**

Both counters are now accumulated in the same poll loop, over 32 passes rather than 24, so neither
side carries a phase. The comparison is quantisation-honest for the first time: each accumulated
count is exact to ±1, so `8 x T0` carries ±8 and the difference ±9 — the previous ±6 was tighter
than its own instrument could support, and passed only because the two phases happened to correlate.
The band is now ±10 and still discriminating by a wide margin: a core running every timer from the
8 kHz stage lands some ninety ticks out. Measured across hosts, the same interval yields T0 = 12-13
and T2 = 99 — the one-tick spread in T0 is precisely the sensitivity that ±6 could not absorb.

- **`C11.12` is unauthorable, verified two ways — there is nothing to assert.** Checked rather than
assumed: the row's subject ("Mode 7 scroll offset latch timing") appears exactly **twice** in
`ref-docs/`, both times as a game-compatibility table row naming NHL '94, with **no behavioural
Expand Down
7 changes: 5 additions & 2 deletions crates/rustysnes-test-harness/tests/accuracysnes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,10 +626,13 @@ const GROUP_E_BATCH_SLOTS: [(u16, &str); 19] = [
"B2.07 APU ticks over 48 frames (~6398 NTSC, ~7689 PAL)",
),
(276, "B2.07 measured frame height (261 NTSC, 311 PAL)"),
(266, "E3.06 timer 0 ticks over the interval"),
(
266,
"E3.06 timer 0 ticks, accumulated across polls (expect ~13)",
),
(
267,
"E3.06 timer 2 ticks over the SAME interval (TnOUT wraps at 16)",
"E3.06 timer 2 ticks over the SAME interval (expect ~8x slot 266)",
),
];

Expand Down
2 changes: 1 addition & 1 deletion tests/roms/AccuracySNES/ERROR_CODES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2668,7 +2668,7 @@ Provenance: **Documented** (SNESdev Wiki, SPC700 timers; fullsnes). Kind: scored
| Code | Byte | Meaning |
|---|---|---|
| 1 | `$02` | timer 0 did not tick over this interval, or ticked far more than the poll loop allows for — either way the interval is not the one this test needs and the ratio below means nothing |
| 2 | `$04` | timer 2 did not count within six ticks of eight times timer 0 over the same interval, so it is not running from the 64 kHz stage — a core running every timer at 8 kHz lands near a seventh of the expected count. The two raw counts are in slots 266 and 267 |
| 2 | `$04` | timer 2 did not count within ten ticks of eight times timer 0 over the same interval, so it is not running from the 64 kHz stage — a core running every timer at 8 kHz lands near a seventh of the expected count, which is some ninety ticks out. The two raw counts are in slots 266 and 267 |

### E3.08 — TEST bit 0 halts timers

Expand Down
30 changes: 17 additions & 13 deletions tests/roms/AccuracySNES/asm/tests_group_a.s
Original file line number Diff line number Diff line change
Expand Up @@ -23426,7 +23426,7 @@ CATALOG_IMPL = 1
rep #$30
.a16
.i16
lda #69
lda #83
sta f:V_APU_LEN
lda #$0200
sta f:V_APU_DEST ; APU RAM $0200: clear of the zero page and the stack
Expand Down Expand Up @@ -23478,20 +23478,23 @@ CATALOG_IMPL = 1
.i16
lda f:$7E0101
and #$00FF
; record slot 266: E3.06 timer 0 ticks over the interval
; record slot 266: E3.06 timer 0 ticks, ACCUMULATED across polls (no end-of-interval phase)
sta f:$7EE414
lda f:$7E0102
and #$00FF
; record slot 267: E3.06 timer 2 ticks, ACCUMULATED across polls (no 4-bit ceiling)
sta f:$7EE416
; Timer 0 first: the interval has to contain a couple of its ticks for a ratio to exist.
; Timer 0 first: the interval has to contain several of its ticks for a ratio to exist. The
; band is wide because the poll loop's cycle cost is a per-core detail the ratio below is
; designed to be insensitive to; this guard only has to reject `nothing ticked` and a run
; length that would wrap a byte accumulator.
lda f:$7E0101
and #$00FF
cmp #$0001
cmp #$0006
bcs :+
jmp @fail1
:
cmp #$0007
cmp #$0015
bcc :+
jmp @fail1
:
Expand All @@ -23510,13 +23513,13 @@ CATALOG_IMPL = 1
sec
sbc f:$7E01F0 ; the signed error against the ideal ratio
clc
adc #$0006 ; biased by 6 so the allowed band is 0..12 rather than -6..+6
adc #$000A ; biased by 10 so the allowed band is 0..20 rather than -10..+10
and #$00FF
cmp #$0000
bcs :+
jmp @fail2
:
cmp #$000D
cmp #$0015
bcc :+
jmp @fail2
:
Expand All @@ -23541,7 +23544,7 @@ CATALOG_IMPL = 1
sta f:$7EE010
jml test_restore
@fail2:
; timer 2 did not count within six ticks of eight times timer 0 over the same interval, so it is not running from the 64 kHz stage — a core running every timer at 8 kHz lands near a seventh of the expected count. The two raw counts are in slots 266 and 267
; timer 2 did not count within ten ticks of eight times timer 0 over the same interval, so it is not running from the 64 kHz stage — a core running every timer at 8 kHz lands near a seventh of the expected count, which is some ninety ticks out. The two raw counts are in slots 266 and 267
sep #$20
.a8
lda #$04
Expand Down Expand Up @@ -34947,11 +34950,12 @@ apu_prog_113:
.byte $68, $A5, $D0, $FA, $E8, $80, $C4, $F1, $5F, $C0, $FF
apu_prog_114:
.byte $CD, $EF, $BD, $8F, $01, $FA, $8F, $01, $FC, $8F, $00, $10
.byte $8F, $00, $11, $E4, $FD, $E4, $FF, $8F, $85, $F1, $E4, $FF
.byte $C4, $12, $E4, $10, $60, $84, $12, $C4, $10, $AB, $11, $E4
.byte $11, $68, $18, $D0, $ED, $8F, $80, $F1, $E4, $FD, $C4, $F6
.byte $E4, $10, $C4, $F7, $E8, $5A, $C4, $F4, $E4, $F4, $68, $A5
.byte $D0, $FA, $E8, $80, $C4, $F1, $5F, $C0, $FF
.byte $8F, $00, $11, $8F, $00, $13, $E4, $FD, $E4, $FF, $8F, $85
.byte $F1, $E4, $FF, $C4, $12, $E4, $10, $60, $84, $12, $C4, $10
.byte $E4, $FD, $C4, $12, $E4, $13, $60, $84, $12, $C4, $13, $AB
.byte $11, $E4, $11, $68, $20, $D0, $E2, $8F, $80, $F1, $E4, $13
.byte $C4, $F6, $E4, $10, $C4, $F7, $E8, $5A, $C4, $F4, $E4, $F4
.byte $68, $A5, $D0, $FA, $E8, $80, $C4, $F1, $5F, $C0, $FF
apu_prog_115:
.byte $CD, $EF, $BD, $8F, $01, $FA, $8F, $0B, $F0, $8F, $81, $F1
.byte $8D, $00, $FE, $FE, $8F, $80, $F1, $E4, $FD, $C4, $F6, $8F
Expand Down
Binary file modified tests/roms/AccuracySNES/build/accuracysnes-pal.sfc
Binary file not shown.
Binary file modified tests/roms/AccuracySNES/build/accuracysnes.sfc
Binary file not shown.
98 changes: 68 additions & 30 deletions tests/roms/AccuracySNES/gen/src/tests/apu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8306,32 +8306,18 @@ fn e6_02d() -> Test {
)
}

/// Timer 2 counts **eight times faster** than timer 0 at the same divider.
/// Build `E3.06`'s SPC700 program: run timers 0 and 2 over one interval, accumulating BOTH.
///
/// The two timers are fed from different taps of the same clock: `T0` and `T1` from an 8 kHz stage,
/// `T2` from a 64 kHz one, so `TnDIV` means eight times as much wall time on `T0` as on `T2`. A
/// core that runs all three timers off one rate is the obvious mistake, and it is invisible to
/// every other timer test on this cart — `E3.01`, `E3.05` and `E2.01` all use `T0` alone, and a
/// uniform-rate core passes all of them.
///
/// Both timers run over the *same* interval, started by one write and stopped by another, so this
/// is a ratio rather than two independent measurements: whatever the interval actually was, `T2`
/// must show about eight times what `T0` does. The interval is chosen short enough that `T2`'s
/// four-bit counter cannot wrap — a wrap would read as a *small* number and look like a slow timer,
/// which is the one failure this test could not tell from a pass.
///
/// The assertion is a pair of ranges, not two exact counts. Where the interval falls relative to
/// each timer's internal divider phase decides whether the last tick lands inside it, so ±1 is not
/// a defect; a factor of eight is far outside that. A uniform-rate core reads `$01` where this
/// wants nine or more.
fn e3_06() -> Test {
let mut prog = Spc::new();
/// Split out of [`e3_06`] because the reasoning below is longer than the code, and the code is
/// longer than one screen once both accumulators are in the loop.
fn e3_06_program(prog: &mut Spc) {
prog.mov_x_imm(0xEF)
.mov_sp_x()
.mov_dp_imm(0xFA, 0x01) // T0DIV = 1
.mov_dp_imm(0xFC, 0x01) // T2DIV = 1
.mov_dp_imm(0x10, 0x00) // accumulated timer-2 ticks
.mov_dp_imm(0x11, 0x00) // poll counter
.mov_dp_imm(0x13, 0x00) // accumulated timer-0 ticks
.mov_a_dp(0xFD) // drain both counters so the interval starts from zero
Comment on lines 8318 to 8321
.mov_a_dp(0xFF)
.mov_dp_imm(0xF1, 0x85); // enable timers 0 and 2 together; bit 7 keeps the IPL mapped
Expand All @@ -8343,48 +8329,99 @@ fn e3_06() -> Test {
// instrument's ceiling. ares read 0 here where RustySNES read 10, and the row could not tell
// "timer 2 wrapped" from "timer 2 is not running at 64 kHz".
//
// Each pass is ~32 SPC cycles and timer 2 ticks every 32, so every read returns 0, 1 or 2 and
// the running sum cannot lose a tick however long the interval gets.
// BOTH timers are polled, and that is the 2026-08-02 correction. Timer 2 was already polled;
// timer 0 was read exactly ONCE at the end, on the reasoning that a handful of ticks is nowhere
// near its four-bit wrap. True about magnitude, and silent about PHASE -- a single read samples
// whatever sub-tick phase the previous test left timer 0 in, so the reading was reproducible
// only as long as nothing before it disturbed that phase. Adding a row that drives timer 2 hard
// (the `E2.10` cycle instrument) broke it on the battery RESTART, where the residue crosses the
// boundary: `$F1`'s enable-raise resets stage2/stage3 (`E3.02`) but nothing resets stage0.
// Accumulating both removes the phase from both sides.
//
// Each pass is ~51 SPC cycles. Timer 2 ticks every 16 opcode cycles and timer 0 every 128, so
// every T2OUT read returns 0-4 and every T0OUT read 0 or 1 -- neither can lose a tick however
// long the interval runs, and the byte accumulators stay well short of wrapping.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let poll = prog.here();
prog.mov_a_dp(0xFF) // T2OUT, read-and-clear
.mov_dp_a(0x12)
.mov_a_dp(0x10)
.clrc()
.adc_a_dp(0x12)
.mov_dp_a(0x10)
.mov_a_dp(0xFD) // T0OUT, read-and-clear, in the SAME loop
.mov_dp_a(0x12)
.mov_a_dp(0x13)
.clrc()
.adc_a_dp(0x12)
.mov_dp_a(0x13)
.inc_dp(0x11)
.mov_a_dp(0x11)
.cmp_a_imm(0x18); // 24 passes: enough for timer 0 to tick two or three times
.cmp_a_imm(0x20); // 32 passes: ~1630 cycles, so timer 0 ticks a dozen times and timer 2 ~100
prog.bne_back(poll);

prog.mov_dp_imm(0xF1, 0x80) // stop them together
.mov_a_dp(0xFD) // timer 0, read once -- a handful of ticks, nowhere near its own wrap
.mov_a_dp(0x13) // the ACCUMULATED timer-0 ticks, not a single end-of-interval read
.mov_dp_a(PORT2)
.mov_a_dp(0x10)
.mov_dp_a(PORT3)
.mov_a_imm(DONE)
.mov_dp_a(PORT0)
.release_to_ipl();
}

/// Timer 2 counts **eight times faster** than timer 0 at the same divider.
///
/// The two timers are fed from different taps of the same clock: `T0` and `T1` from an 8 kHz stage,
/// `T2` from a 64 kHz one, so `TnDIV` means eight times as much wall time on `T0` as on `T2`. A
/// core that runs all three timers off one rate is the obvious mistake, and it is invisible to
/// every other timer test on this cart — `E3.01`, `E3.05` and `E2.01` all use `T0` alone, and a
/// uniform-rate core passes all of them.
///
/// Both timers run over the *same* interval, started by one write and stopped by another, so this
/// is a ratio rather than two independent measurements: whatever the interval actually was, `T2`
/// must show about eight times what `T0` does. **Both are accumulated across polls**, which is what
/// keeps `TnOUT`'s four-bit read-and-clear counter out of the answer — a wrap would read as a
/// *small* number and look like a slow timer, the one failure this test could not tell from a pass.
///
/// Polling both sides is also what makes the row reproducible. A single end-of-interval read
/// samples whatever sub-tick phase the previous test left that timer in, and nothing resets a
/// timer's stage 0 — so the reading was stable only while nothing upstream disturbed the phase, and
/// a later row that drove timer 2 hard broke it on the battery's *second* run.
///
/// The assertion is a range, not two exact counts. Each accumulated count is exact to ±1, so `8 x
/// T0` carries ±8 and their difference ±9; the band is ±10. A factor of eight is far outside that —
/// a core running every timer from the 8 kHz stage lands some ninety ticks out.
fn e3_06() -> Test {
let mut prog = Spc::new();
e3_06_program(&mut prog);

let mut a = Asm::new();
upload_and_run(&mut a, &prog);
a.l("rep #$30");
a.l("lda f:$7E0101");
a.l("and #$00FF");
a.record(266, "E3.06 timer 0 ticks over the interval");
a.record(
266,
"E3.06 timer 0 ticks, ACCUMULATED across polls (no end-of-interval phase)",
);
a.l("lda f:$7E0102");
a.l("and #$00FF");
a.record(
267,
"E3.06 timer 2 ticks, ACCUMULATED across polls (no 4-bit ceiling)",
);

a.c("Timer 0 first: the interval has to contain a couple of its ticks for a ratio to exist.");
a.c(
"Timer 0 first: the interval has to contain several of its ticks for a ratio to exist. The",
);
a.c("band is wide because the poll loop's cycle cost is a per-core detail the ratio below is");
a.c("designed to be insensitive to; this guard only has to reject `nothing ticked` and a run");
a.c("length that would wrap a byte accumulator.");
a.l("lda f:$7E0101");
a.l("and #$00FF");
a.assert_a16_range(
1,
6,
20,
"timer 0 did not tick over this interval, or ticked far more than the poll loop allows for \
— either way the interval is not the one this test needs and the ratio below means nothing",
);
Expand All @@ -8406,14 +8443,15 @@ fn e3_06() -> Test {
a.l("sec");
a.l("sbc f:$7E01F0 ; the signed error against the ideal ratio");
a.l("clc");
a.l("adc #$0006 ; biased by 6 so the allowed band is 0..12 rather than -6..+6");
a.l("adc #$000A ; biased by 10 so the allowed band is 0..20 rather than -10..+10");
a.l("and #$00FF");
a.assert_a16_range(
0,
12,
"timer 2 did not count within six ticks of eight times timer 0 over the same interval, so \
20,
"timer 2 did not count within ten ticks of eight times timer 0 over the same interval, so \
it is not running from the 64 kHz stage — a core running every timer at 8 kHz lands near \
a seventh of the expected count. The two raw counts are in slots 266 and 267",
a seventh of the expected count, which is some ninety ticks out. The two raw counts are \
in slots 266 and 267",
Comment on lines +8451 to +8454

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the ratio fraction in the diagnostic.

When both timers run from the 8 kHz stage, T2 is approximately T0. The expected value is 8 * T0, so that result is about one-eighth of the expected count, not a seventh. Correct the diagnostic.

Proposed wording
-         a seventh of the expected count, which is some ninety ticks out.
+         one-eighth of the expected count, which is some ninety ticks out.

As per path instructions, AccuracySNES test prose must match the behavior that the emitted program validates.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"timer 2 did not count within ten ticks of eight times timer 0 over the same interval, so \
it is not running from the 64 kHz stage — a core running every timer at 8 kHz lands near \
a seventh of the expected count. The two raw counts are in slots 266 and 267",
a seventh of the expected count, which is some ninety ticks out. The two raw counts are \
in slots 266 and 267",
"timer 2 did not count within ten ticks of eight times timer 0 over the same interval, so \
it is not running from the 64 kHz stage — a core running every timer at 8 kHz lands near \
one-eighth of the expected count, which is some ninety ticks out. The two raw counts are \
in slots 266 and 267",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/roms/AccuracySNES/gen/src/tests/apu.rs` around lines 8451 - 8454,
Correct the diagnostic text in the timer 2 versus timer 0 assertion to state
that an 8 kHz core produces approximately one-eighth of the expected count,
replacing the incorrect “seventh” wording. Preserve the surrounding explanation
and slot references.

Source: Path instructions

);
apu_timeout_arm(&mut a);
a.finish(
Expand Down
Loading