1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 17:28:00 +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

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

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

View file

@ -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]