From 109e3dab754d568587f9cf3faee41d238f2e5c5d Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Sun, 4 Oct 2026 14:13:29 +0200 Subject: [PATCH 1/4] test(nr-3wg): pin that a taken same-page branch delays NMI as NESdev says Tekken 2's attract-mode fight differs from Mesen2 from frame 1307 because Mesen2 lets an NMI detected in a taken branch's second cycle through, where NESdev's CPU interrupts page says the branch does not poll again. The navigator chose the specification; this test keeps it that way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVAhbPCxrs1fRqHCgG2Cd5 --- src/nes/cpu/cpu/tests.rs | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/nes/cpu/cpu/tests.rs b/src/nes/cpu/cpu/tests.rs index 4ab474402..f00e2dc31 100644 --- a/src/nes/cpu/cpu/tests.rs +++ b/src/nes/cpu/cpu/tests.rs @@ -14500,3 +14500,76 @@ fn test_nmis_taken_counts_an_nmi_that_hijacks_irq_entry() { assert_eq!(cpu.pc, 0x9000, "the NMI took over the interrupt sequence"); assert_eq!(cpu.nmis_taken(), 1); } + +/// Whether the vblank NMI is serviced right after one instruction, when the PPU is +/// `dots_before_vblank` dots short of setting the vblank flag as the instruction starts. +fn nmi_taken_after_one_instruction( + program: &[u8], + zero_flag: bool, + dots_before_vblank: u32, +) -> bool { + let (ppu, apu, memory) = create_test_memory(); + let mut cpu = Cpu::new( + TimingMode::Ntsc, + Rc::clone(&memory), + Rc::clone(&ppu), + Rc::clone(&apu), + ); + let mut prg_rom = vec![0xEA; 0x4000]; + prg_rom[0x3FFA] = 0x00; // NMI vector -> $9000 + prg_rom[0x3FFB] = 0x90; + prg_rom[0x3FFC] = 0x00; // reset vector -> $8000 + prg_rom[0x3FFD] = 0x80; + prg_rom[..program.len()].copy_from_slice(program); + let cartridge = Cartridge::from_parts(prg_rom, vec![0; 0x2000], NametableLayout::Horizontal); + cpu.bus.borrow_mut().map_cartridge(cartridge); + cpu.reset(true); + if zero_flag { + cpu.p |= FLAG_ZERO; + } else { + cpu.p &= !FLAG_ZERO; + } + + ppu.borrow_mut().write_control(0x80); + // The vblank flag (and with it the NMI line) is set at scanline 241, dot 1. + let (scanline, dot) = (ppu.borrow().scanline() as u32, ppu.borrow().pixel() as u32); + let now = scanline * 341 + dot; + ppu.borrow_mut() + .run_ppu_cycles(u64::from(241 * 341 + 1 - now - dots_before_vblank)); + + cpu.execute(); + cpu.pc == 0x9000 +} + +#[test] +fn test_taken_branch_without_page_cross_delays_nmi_like_a_two_cycle_instruction() { + // NESdev, CPU interrupts, "Branch instructions and interrupts": interrupts are polled + // before the second cycle of a branch, "but not before the third CPU cycle on a taken + // branch" that stays on its page. So a taken BEQ polls exactly where a two-cycle NOP + // does, and an NMI that first appears during its second cycle waits one more + // instruction. + // + // Mesen2 applies this to IRQ only (NesCpu.h BranchRelative), so it takes that NMI + // straight after the branch. The navigator chose the specification (nr-3wg): this is + // why Tekken 2's attract-mode fight differs from Mesen2's from frame 1307. + const BEQ_TAKEN_SAME_PAGE: &[u8] = &[0xF0, 0x00]; + const NOP: &[u8] = &[0xEA]; + const LDA_ZP: &[u8] = &[0xA5, 0x00]; // three cycles, polls before its third + + let mut nmi_arrives_in_second_cycle = false; + for dots in 0..=30 { + let after_nop = nmi_taken_after_one_instruction(NOP, true, dots); + let after_branch = nmi_taken_after_one_instruction(BEQ_TAKEN_SAME_PAGE, true, dots); + let after_lda = nmi_taken_after_one_instruction(LDA_ZP, true, dots); + assert_eq!( + after_branch, after_nop, + "vblank {dots} dots ahead: a taken same-page branch must poll NMI where a \ + two-cycle instruction does (NOP: {after_nop}, BEQ: {after_branch})" + ); + nmi_arrives_in_second_cycle |= after_lda && !after_nop; + } + assert!( + nmi_arrives_in_second_cycle, + "the sweep must include an NMI that first appears in the second cycle" + ); +} From 203c630ec281e51ef351c6be411ad00db30a56ab Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Sun, 4 Oct 2026 15:01:31 +0200 Subject: [PATCH 2/4] test(nr-3wg): also pin that a branch not taken polls like a two-cycle instruction Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVAhbPCxrs1fRqHCgG2Cd5 --- src/nes/cpu/cpu/tests.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/nes/cpu/cpu/tests.rs b/src/nes/cpu/cpu/tests.rs index f00e2dc31..d13cfeb0b 100644 --- a/src/nes/cpu/cpu/tests.rs +++ b/src/nes/cpu/cpu/tests.rs @@ -14560,7 +14560,13 @@ fn test_taken_branch_without_page_cross_delays_nmi_like_a_two_cycle_instruction( for dots in 0..=30 { let after_nop = nmi_taken_after_one_instruction(NOP, true, dots); let after_branch = nmi_taken_after_one_instruction(BEQ_TAKEN_SAME_PAGE, true, dots); + let after_branch_not_taken = + nmi_taken_after_one_instruction(BEQ_TAKEN_SAME_PAGE, false, dots); let after_lda = nmi_taken_after_one_instruction(LDA_ZP, true, dots); + assert_eq!( + after_branch_not_taken, after_nop, + "vblank {dots} dots ahead: a branch not taken is a two-cycle instruction" + ); assert_eq!( after_branch, after_nop, "vblank {dots} dots ahead: a taken same-page branch must poll NMI where a \ From c2c405f8efb8e512d33edd9beae93d06cb7fb609 Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Sun, 4 Oct 2026 15:13:23 +0200 Subject: [PATCH 3/4] =?UTF-8?q?docs(nr-3wg):=20retrospective=20=E2=80=94?= =?UTF-8?q?=20re-derived=20the=20Mesen2=20NMI-on-branch=20difference=20nr-?= =?UTF-8?q?046=20had=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVAhbPCxrs1fRqHCgG2Cd5 --- docs/retrospectives/nr-3wg.md | 27 +++++++++++++++++++++++++++ src/nes/cpu/cpu/tests.rs | 3 ++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 docs/retrospectives/nr-3wg.md diff --git a/docs/retrospectives/nr-3wg.md b/docs/retrospectives/nr-3wg.md new file mode 100644 index 000000000..84b3fc183 --- /dev/null +++ b/docs/retrospectives/nr-3wg.md @@ -0,0 +1,27 @@ +# nr-3wg — retrospective + +- **Implementer:** Shadowcat +- **Date:** 2026-10-04 +- **PR:** #3339 + +## I re-derived a Mesen2/NESdev difference that nr-046 had already recorded and put to the navigator + +**What happened.** Tekken 2's attract-mode fight drifts from Mesen2 from frame 1307. I traced it +with per-frame RAM dumps and exec traces to an NMI arriving in a taken same-page branch's second +cycle. NESdev delays that NMI by one instruction; Mesen2's `BranchRelative` delays only IRQ. I +confirmed it by experiment (Mesen2's rule gives 0 px at all 12 checkpoints) and asked the +navigator, who chose NESdev. Only when writing this file did I find the same cause, the same +experiment and the same decision in `docs/retrospectives/nr-046.md`, plus a doc comment on +`skip_interrupt_latch_this_cycle` in `src/nes/cpu/cpu/mod.rs`. +**Why.** The fact lived in a retrospective and on a private field, and neither is where you look +when a bead says "diverges from Mesen2". `.cerebro/traps.md`, which planners and implementers read +first, is still empty. nr-3wg's own description (filed from nr-55b) guessed "CPU-cycle or IRQ +timing", without naming this known difference. +**Cost.** About two hours of tracing and a second navigator question for a decision already taken. +**Prevent by.** A `.cerebro/traps.md` entry: "Mesen2 takes an NMI that a taken same-page branch +should delay (NESdev CPU interrupts); NESER keeps NESdev (nr-046, nr-3wg). A Mesen2-parity drift in +a game that waits for vblank in a `BEQ`/`BNE` loop is this until shown otherwise: try Mesen2's rule +in `timing.rs` as an experiment before tracing." The new test, +`test_taken_branch_without_page_cross_delays_nmi_like_a_two_cycle_instruction`, now pins the +behaviour directly, so an accidental "fix" toward Mesen2 fails by name and not just through Ruder. +**Seen before.** nr-046 (the same difference, found in Vs. Duck Hunt). diff --git a/src/nes/cpu/cpu/tests.rs b/src/nes/cpu/cpu/tests.rs index d13cfeb0b..dd46e82f9 100644 --- a/src/nes/cpu/cpu/tests.rs +++ b/src/nes/cpu/cpu/tests.rs @@ -14550,7 +14550,8 @@ fn test_taken_branch_without_page_cross_delays_nmi_like_a_two_cycle_instruction( // instruction. // // Mesen2 applies this to IRQ only (NesCpu.h BranchRelative), so it takes that NMI - // straight after the branch. The navigator chose the specification (nr-3wg): this is + // straight after the branch. The navigator chose the specification (nr-046, again in + // nr-3wg): this is // why Tekken 2's attract-mode fight differs from Mesen2's from frame 1307. const BEQ_TAKEN_SAME_PAGE: &[u8] = &[0xF0, 0x00]; const NOP: &[u8] = &[0xEA]; From 34826816addc95889bb06042b2ef4dad07adc607 Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Sun, 4 Oct 2026 15:13:35 +0200 Subject: [PATCH 4/4] test(nr-3wg): rewrap the comment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVAhbPCxrs1fRqHCgG2Cd5 --- src/nes/cpu/cpu/tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/nes/cpu/cpu/tests.rs b/src/nes/cpu/cpu/tests.rs index dd46e82f9..549dc5bdf 100644 --- a/src/nes/cpu/cpu/tests.rs +++ b/src/nes/cpu/cpu/tests.rs @@ -14551,8 +14551,7 @@ fn test_taken_branch_without_page_cross_delays_nmi_like_a_two_cycle_instruction( // // Mesen2 applies this to IRQ only (NesCpu.h BranchRelative), so it takes that NMI // straight after the branch. The navigator chose the specification (nr-046, again in - // nr-3wg): this is - // why Tekken 2's attract-mode fight differs from Mesen2's from frame 1307. + // nr-3wg): this is why Tekken 2's attract-mode fight differs from Mesen2's from frame 1307. const BEQ_TAKEN_SAME_PAGE: &[u8] = &[0xF0, 0x00]; const NOP: &[u8] = &[0xEA]; const LDA_ZP: &[u8] = &[0xA5, 0x00]; // three cycles, polls before its third