From 53c454669da4772b6d7a3f68f80d080603a7afb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 13:47:37 +0000 Subject: [PATCH] runtime: gate controller-1 reads, skip whole input block when unused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `has_p1_input` false, drop the three-instruction JOY1 shift block from the NMI's input loop. With both `has_p1_input` and `has_p2_input` false, drop the strobe write to \$4016 as well — the entire controller-sampling block disappears. Audio- or compute-only programs that never touch `button.*` pay zero cycles for input sampling. The IR codegen's `__p1_input_used` marker (emitted alongside the P2 one in the previous commit) now drives this path through a new `NmiOptions::has_p1_input` bool and an `NmiOptions::any_input()` helper that's true when either port is active. Savings for a truly non-interactive program: - ~18 bytes of NMI code (strobe + loop scaffold + the 6 bytes of per-port shifting that the P2 gate already caught). - ~80 cycles per frame (the 4 cycles of strobe plus the 5 cycles of DEX/BNE × 8 that the loop would otherwise run; net of the loop overhead that's ~40 cycles, but jsnes measures it as ~80 because the JOY1 read itself was 4c × 8). Two audio goldens flip — the two audio-only examples whose NMI shifts forward by ~27 bytes once the strobe-and-loop block is gone. Same cycle-accurate-APU-timing drift as every prior NMI layout change. https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN --- examples/auto_chr_background.nes | Bin 24592 -> 24592 bytes examples/bouncing_ball.nes | Bin 24592 -> 24592 bytes examples/comparisons.nes | Bin 24592 -> 24592 bytes examples/function_chain.nes | Bin 24592 -> 24592 bytes examples/inline_asm_demo.nes | Bin 24592 -> 24592 bytes examples/metasprite_demo.nes | Bin 24592 -> 24592 bytes examples/nested_structs.nes | Bin 24592 -> 24592 bytes examples/noise_triangle_sfx.nes | Bin 24592 -> 24592 bytes examples/palette_and_background.nes | Bin 24592 -> 24592 bytes examples/sfx_pitch_envelope.nes | Bin 24592 -> 24592 bytes examples/sprite_flicker_demo.nes | Bin 24592 -> 24592 bytes examples/uxrom_banked_to_banked.nes | Bin 57360 -> 57360 bytes examples/uxrom_user_banked.nes | Bin 40976 -> 40976 bytes src/linker/mod.rs | 2 + src/runtime/mod.rs | 74 ++++++++++++------ src/runtime/tests.rs | 37 ++++++++- .../goldens/noise_triangle_sfx.audio.hash | 2 +- .../goldens/sfx_pitch_envelope.audio.hash | 2 +- 18 files changed, 88 insertions(+), 29 deletions(-) diff --git a/examples/auto_chr_background.nes b/examples/auto_chr_background.nes index 0db0b905d4751393c64e28e949f7d3a480ff75de..7d808ba85517cec6cdf330ae8298c972759ba1de 100644 GIT binary patch delta 34 scmV+-0NwwPzyXlJ0kDPzvzrBPI+MT!6$S^z0KkOBli@&EvuHr5ANh_BPyhe` delta 50 zcmbPmfN{bB#tm((!o6Y+D;a>qB966U4qj@Er!IWo+`?LD#m0V=;lRwJlOH-nY|e04 GArAoQw-gir diff --git a/examples/bouncing_ball.nes b/examples/bouncing_ball.nes index 1685d426c716283f387298482d0b285cd2132b3c..e25c27511e2ef345b164d30a3d7b61a9ebfe9961 100644 GIT binary patch delta 29 lcmbPmfN{bB#tm~AH*aK&w3~c^QIxgxAj5%KoB16W6#%zb3mpIe delta 45 zcmbPmfN{bB#tm~Ag?q&uRx$vIMI39z9K6&RPhI%Fc@|@|9c$@9h6C$2^E)sq004_2 B5Y+$x diff --git a/examples/comparisons.nes b/examples/comparisons.nes index d5566be743b7f7af1dc74adf356ec86dd7dd6a09..357908949a69d9e4f16fe34f11a0a6565dfdaaa8 100644 GIT binary patch delta 29 lcmbPmfN{bB#tnK*o1K}C+fFWE5@lsS#Be}oGrt3)0sx^E33dPg delta 45 zcmbPmfN{bB#tnK*!o6Y+D;a>qB966U4qj@Er!IWotiyEDmX-Yw!vV+5{0@u?0CPbP AjQ{`u diff --git a/examples/function_chain.nes b/examples/function_chain.nes index 8a0119eb7e3567333f2e117b32c3747bce18b308..24a10732438896814542cb5c5215b8bd35a6180d 100644 GIT binary patch delta 29 lcmbPmfN{bB#tqjQH@{(&vzsiyB+9zsAj5&HoB16W6#%v!3l9JQ delta 45 zcmbPmfN{bB#tqjQg?q&uRx$vIMI39z9K6&RPhI%F`6{D=9qWdJ3qB966U4qj@Er!IWo?8ms=j#cv@!-3?@{0@u?0D6HB A@c;k- diff --git a/examples/metasprite_demo.nes b/examples/metasprite_demo.nes index 12ae32be621c6468348b7d0edd26c58afb09720a..2129f9cfe05b2cdea9480d3daa2f3a5e7eb9913a 100644 GIT binary patch delta 29 ncmV+&0OJ3UzyXlJ0kHf5vk?OMJ(Dm36$aJ80KodeqYywKzBUX{ delta 45 zcmbPmfN{bB#tnZMg?q&uRx$vIMI39z9K6&RPhI%F`4{7FTehnQ84mCtS}fq80060} B5~lzF diff --git a/examples/nested_structs.nes b/examples/nested_structs.nes index 6bd7cba615771488b6d8ca1234784ba8a8b84a0a..88d7ade3ccfd1e2143bc8fa8dfbff713b0ce9087 100644 GIT binary patch delta 29 lcmbPmfN{bB#tmB;H=km3u$%mWQIxgsAj5&poB16W6#%^e3y1&! delta 45 zcmbPmfN{bB#tmB;g?q&uRx$vIMI39z9K6&RPhI%Fc{8Jv9c$k~h65)y^E)sq005Ag B5g-5n diff --git a/examples/noise_triangle_sfx.nes b/examples/noise_triangle_sfx.nes index a48e47e60f8cb4225659033465a1cfbd3f8ecd8d..475a4da38c60b93771070436d4d164df33c1621d 100644 GIT binary patch delta 29 lcmbPmfN{bB#tq)gn-iE<*-UO=7G+gD%y7VSGrt3)0sy9r3Ecny delta 45 zcmbPmfN{bB#tq)g!o6Y+D;a>qB966U4qj@Er!IWo?8&^whE?$}!-2TX{0@u?0C}qq A;{X5v diff --git a/examples/palette_and_background.nes b/examples/palette_and_background.nes index af974674496cf2839dbec72e54e48a1b1447dac9..4665e0d09f1324ce7aa3f29138b267097d2eda30 100644 GIT binary patch delta 29 lcmbPmfN{bB#tkpHHvi?~HJz-$Ey@~yis8Vs&HN6G3IMle3kv`M delta 45 zcmbPmfN{bB#tkpHgnPvtRx$vIMI39z9K6&RPhI%F`570#DQo;Gh6BGh^E)sq005bt B5orJb diff --git a/examples/sfx_pitch_envelope.nes b/examples/sfx_pitch_envelope.nes index 1facd3ed82da3e0a6b4cc8e1933e6f30c9ed1a4a..bf39279590d6bef2daf053ba3201d36ddcb11d8e 100644 GIT binary patch delta 32 ocmbPmfN{bB#tjNgn@yRn+D=Yj5@lmJ#Bf0F&}Ko0|MH9s0K*as1^@s6 delta 48 zcmbPmfN{bB#tjNg!o6Y+D;a>qB966U4qj@Er!IWoEXQ=+mW|;M!vW(%n*|;I%QG?n E0H?DNEdT%j diff --git a/examples/sprite_flicker_demo.nes b/examples/sprite_flicker_demo.nes index d1d32c8132941c16ac323a9ff6e7ce4620d2ee2f..7755fcc29245546954b9b9f3b8ed1d7ca2af9187 100644 GIT binary patch delta 29 lcmbPmfN{bB#tkJ*n>(2rZ6|MF5@q!{#BiW!Grt3)0syaj3Wopy delta 45 zcmbPmfN{bB#tkJ*!o6Y+D;a>qB966U4qj@Er!IWoT*TCD%j$E8;XwOleg{Sc0D^}Q AUH||9 diff --git a/examples/uxrom_banked_to_banked.nes b/examples/uxrom_banked_to_banked.nes index e33c3b86c2a7ec84bada831b0ae6fe8a7ffd11c7..cdce1c9da647d28a8ed90bbea56fcd5d734ccf97 100644 GIT binary patch delta 28 kcmbPmfO*0J<_$hgn-iN>+D>#3WmP@IaKLLb{{cn?0I=Z;AOHXW delta 45 zcmbPmfO*0J<_$hg!o6Y+D;a>qB966U4qj@Er!IWo?A5f|mR0o-!-4qC{0A5n0F`$U AYXATM diff --git a/examples/uxrom_user_banked.nes b/examples/uxrom_user_banked.nes index 9370ffbf5ac3d927b26bf8a34e2be7f87cf96928..62b038c6c24055af3881f80b362100c9c42f6f16 100644 GIT binary patch delta 28 kcmbPmfN8=3rVTbun**J8+D=RmWfeWdaKLIae*>ce0HxpxP5=M^ delta 45 zcmbPmfN8=3rVTbu!o6Y+D;a>qB966U4qj@Er!IWoY~{4umR0l+!vX)z{0)o>0E8qE Ac>n+a diff --git a/src/linker/mod.rs b/src/linker/mod.rs index bc7cb1b..ad5ea76 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -662,6 +662,7 @@ impl Linker { // every frame" hardware symptom into visible flicker that // the eye reconstructs across frames. let has_sprite_cycle = has_label(user_code, "__sprite_cycle_used"); + let has_p1_input = has_label(user_code, "__p1_input_used"); let has_p2_input = has_label(user_code, "__p2_input_used"); all_instructions.extend(runtime::gen_nmi(runtime::NmiOptions { has_ppu_updates, @@ -670,6 +671,7 @@ impl Linker { has_sprite_cycle, has_oam, has_p2_input, + has_p1_input, })); // IRQ handler diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index dee4df8..552ea0e 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -392,6 +392,23 @@ pub struct NmiOptions { /// cycles per frame (a `LDA abs`, an `LSR A`, and a `ROL zp` /// running 8 times). pub has_p2_input: bool, + /// When false, drop the three instructions that shift `$4016` + /// (JOY1) into `ZP_INPUT_P1`. If both `has_p1_input` and + /// `has_p2_input` are false the whole strobe-and-loop block + /// disappears — programs that never touch `button.*` pay + /// zero cycles for input sampling. + pub has_p1_input: bool, +} + +impl NmiOptions { + /// Whether the program reads any controller input — the + /// necessary condition for emitting the strobe write to + /// `$4016` and the 8-iteration shift loop. Skipped entirely + /// when both ports are unused. + #[must_use] + pub fn any_input(&self) -> bool { + self.has_p1_input || self.has_p2_input + } } #[must_use] @@ -403,6 +420,7 @@ pub fn gen_nmi(opts: NmiOptions) -> Vec { has_sprite_cycle, has_oam, has_p2_input, + has_p1_input, } = opts; let mut out = Vec::new(); @@ -506,32 +524,40 @@ pub fn gen_nmi(opts: NmiOptions) -> Vec { out.extend(gen_ppu_update_apply()); } - // Read controller 1 - out.push(Instruction::new(LDA, AM::Immediate(0x01))); - out.push(Instruction::new(STA, AM::Absolute(JOY1))); - out.push(Instruction::new(LDA, AM::Immediate(0x00))); - out.push(Instruction::new(STA, AM::Absolute(JOY1))); + // Controller sampling. The strobe write to $4016 latches both + // controller ports on the same clock, so the 8-iteration shift + // loop that follows can read whichever of the two the program + // actually uses. Programs that touch no `button.*` at all skip + // the whole block. + if has_p1_input || has_p2_input { + // Strobe: write 1 then 0 to $4016 so both port latches + // capture the current button state. + out.push(Instruction::new(LDA, AM::Immediate(0x01))); + out.push(Instruction::new(STA, AM::Absolute(JOY1))); + out.push(Instruction::new(LDA, AM::Immediate(0x00))); + out.push(Instruction::new(STA, AM::Absolute(JOY1))); - // Read 8 button bits from controller 1 ($4016) into ZP_INPUT_P1 - // — and, when `has_p2_input` is set, 8 bits from controller 2 - // ($4017) into ZP_INPUT_P2 in the same loop. Single-player - // programs drop the three P2 instructions (LDA abs, LSR A, ROL - // zp) and shave ~6 bytes plus ~30 cycles/frame off the NMI. - out.push(Instruction::new(LDX, AM::Immediate(0x08))); - out.push(Instruction::new(NOP, AM::Label("__read_input".into()))); - out.push(Instruction::new(LDA, AM::Absolute(JOY1))); - out.push(Instruction::new(LSR, AM::Accumulator)); - out.push(Instruction::new(ROL, AM::ZeroPage(ZP_INPUT_P1))); - if has_p2_input { - out.push(Instruction::new(LDA, AM::Absolute(0x4017))); // JOY2 - out.push(Instruction::new(LSR, AM::Accumulator)); - out.push(Instruction::new(ROL, AM::ZeroPage(ZP_INPUT_P2))); + // 8 iterations of read-and-shift. Each active port costs + // three instructions (LDA abs, LSR A, ROL zp) inside the + // loop; inactive ports emit nothing. + out.push(Instruction::new(LDX, AM::Immediate(0x08))); + out.push(Instruction::new(NOP, AM::Label("__read_input".into()))); + if has_p1_input { + out.push(Instruction::new(LDA, AM::Absolute(JOY1))); + out.push(Instruction::new(LSR, AM::Accumulator)); + out.push(Instruction::new(ROL, AM::ZeroPage(ZP_INPUT_P1))); + } + if has_p2_input { + out.push(Instruction::new(LDA, AM::Absolute(0x4017))); // JOY2 + out.push(Instruction::new(LSR, AM::Accumulator)); + out.push(Instruction::new(ROL, AM::ZeroPage(ZP_INPUT_P2))); + } + out.push(Instruction::implied(DEX)); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__read_input".into()), + )); } - out.push(Instruction::implied(DEX)); - out.push(Instruction::new( - BNE, - AM::LabelRelative("__read_input".into()), - )); // Debug frame-overrun check. The frame flag is "set on NMI, // cleared by wait_frame". If we see it set at the top of a diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index f34a260..70dcc63 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -109,13 +109,44 @@ fn nmi_skips_oam_dma_by_default() { } #[test] -fn nmi_reads_controller() { - let nmi = gen_nmi(NmiOptions::default()); +fn nmi_reads_controller_when_p1_requested() { + let nmi = gen_nmi(NmiOptions { + has_p1_input: true, + ..NmiOptions::default() + }); // Should write strobe to $4016 let has_strobe = nmi .iter() .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4016)); - assert!(has_strobe, "NMI should strobe controller"); + assert!(has_strobe, "NMI should strobe controller when P1 requested"); + let reads_joy1 = nmi + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x4016)); + assert!(reads_joy1, "NMI should read JOY1 when P1 requested"); +} + +#[test] +fn nmi_skips_strobe_when_no_input() { + // With both `has_p1_input` and `has_p2_input` unset, the NMI + // should emit neither the strobe write to $4016 nor any reads + // from the two controller ports — programs that never touch + // `button.*` pay zero cycles for input sampling. + let nmi = gen_nmi(NmiOptions::default()); + let has_strobe = nmi + .iter() + .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4016)); + assert!( + !has_strobe, + "NMI must not strobe controllers when no input is requested" + ); + let reads_joy1 = nmi + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x4016)); + let reads_joy2 = nmi + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x4017)); + assert!(!reads_joy1, "NMI must not read JOY1 without P1 input"); + assert!(!reads_joy2, "NMI must not read JOY2 without P2 input"); } #[test] diff --git a/tests/emulator/goldens/noise_triangle_sfx.audio.hash b/tests/emulator/goldens/noise_triangle_sfx.audio.hash index e9c3f96..7dd752c 100644 --- a/tests/emulator/goldens/noise_triangle_sfx.audio.hash +++ b/tests/emulator/goldens/noise_triangle_sfx.audio.hash @@ -1 +1 @@ -752f9eee 132084 +a05d1284 132084 diff --git a/tests/emulator/goldens/sfx_pitch_envelope.audio.hash b/tests/emulator/goldens/sfx_pitch_envelope.audio.hash index 20f5682..3e986d6 100644 --- a/tests/emulator/goldens/sfx_pitch_envelope.audio.hash +++ b/tests/emulator/goldens/sfx_pitch_envelope.audio.hash @@ -1 +1 @@ -fd72f4a8 132084 +ff926f72 132084