1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 17:06:04 +00:00

runtime: gate controller-1 reads, skip whole input block when unused

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
This commit is contained in:
Claude 2026-04-16 13:47:37 +00:00
parent 0de1d60c33
commit 53c454669d
No known key found for this signature in database
18 changed files with 88 additions and 29 deletions

View file

@ -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<Instruction> {
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<Instruction> {
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