1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-10 09:42:37 +00:00

runtime: gate controller-2 reads in NMI on __p2_input_used

Drop the three-instruction JOY2 shift block (`LDA $4017 / LSR A /
ROL ZP_INPUT_P2`) from inside the NMI's 8-iteration input loop
when user code never reads controller 2. IR codegen emits the
`__p2_input_used` marker from `IrOp::ReadInput(_, 1)`; the linker
threads the flag through a new `NmiOptions::has_p2_input` bool,
and `gen_nmi` writes the shift block only when the flag is set.

Savings for single-player programs:
 - ~6 bytes of NMI code.
 - ~30 cycles per frame (3 instructions × 8 loop iterations, each
   6-8 cycles depending on addressing — LDA abs is 4, LSR A is 2,
   ROL zp is 5, so ~11 cycles × 8 = ~88 cycles; rounded down for
   the page-crossing penalty landing differently in the new layout).

This commit also fixes the IR codegen to drop the matching
`__p1_input_used` marker from `IrOp::ReadInput(_, 0)`, even though
the next commit is the one that actually consumes it. Landing the
two markers together keeps the IR codegen's per-op bookkeeping
coherent.

Six audio goldens flip (every program that reads input + plays
audio) with the expected NMI-layout-shift cycle drift.

https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
This commit is contained in:
Claude 2026-04-16 13:43:33 +00:00
parent bd30ac3010
commit 0de1d60c33
No known key found for this signature in database
40 changed files with 70 additions and 11 deletions

View file

@ -196,6 +196,18 @@ pub struct IrCodeGen<'a> {
/// `false` and reclaim the 16 CHR bytes of tile 0 as a blank
/// background tile.
default_sprite_used: bool,
/// Set to true the first time we lower an `IrOp::ReadInput`
/// targeting player 1. Drives the `__p1_input_used` marker
/// label — the runtime's NMI skips the three instructions that
/// shift `$4016` into `ZP_INPUT_P1` when nobody reads the
/// player-1 byte.
p1_input_used: bool,
/// Set to true the first time we lower an `IrOp::ReadInput`
/// targeting player 2. Drives the `__p2_input_used` marker
/// label — single-player programs skip the `$4017` read inside
/// the NMI's shift loop, saving ~6 bytes of code and ~30 cycles
/// per frame.
p2_input_used: bool,
/// Source-location markers produced from [`IrOp::SourceLoc`].
/// Each entry is a `(label_name, span)` pair — the codegen
/// emits a unique label-definition pseudo-op at the current
@ -377,6 +389,8 @@ impl<'a> IrCodeGen<'a> {
div_used: false,
oam_used: false,
default_sprite_used: false,
p1_input_used: false,
p2_input_used: false,
source_locs: Vec::new(),
next_source_loc: 0,
emit_source_locs: false,
@ -1490,6 +1504,16 @@ impl<'a> IrCodeGen<'a> {
self.emit(INC, AM::ZeroPage(ZP_OAM_CURSOR));
}
IrOp::ReadInput(dest, player) => {
// Drop the per-player input marker so the linker
// can decide whether to keep that port's shift
// block inside NMI. IR uses `player_index` 0 = P1
// and 1 = P2; the ZP bytes the NMI populates match
// the constants at the top of `runtime/mod.rs`.
if *player == 1 {
self.emit_p2_input_marker();
} else {
self.emit_p1_input_marker();
}
// $01 = P1 input byte, $08 = P2 input byte
let addr = if *player == 1 { 0x08 } else { 0x01 };
self.emit(LDA, AM::ZeroPage(addr));
@ -2141,6 +2165,28 @@ impl<'a> IrCodeGen<'a> {
}
}
/// Emit the `__p1_input_used` marker label at most once per
/// program. Triggered by `IrOp::ReadInput` with `player == 0`.
/// Programs that never read controller 1 skip the three NMI
/// instructions that shift `$4016` into `ZP_INPUT_P1`.
fn emit_p1_input_marker(&mut self) {
if !self.p1_input_used {
self.emit_label("__p1_input_used");
self.p1_input_used = true;
}
}
/// Emit the `__p2_input_used` marker label at most once per
/// program. Triggered by `IrOp::ReadInput` with `player == 1`.
/// Single-player programs skip the three NMI instructions that
/// shift `$4017` into `ZP_INPUT_P2`.
fn emit_p2_input_marker(&mut self) {
if !self.p2_input_used {
self.emit_label("__p2_input_used");
self.p2_input_used = true;
}
}
/// Emit the MMC3 `__irq_user` handler that dispatches on the
/// `(current_state, scanline_step)` pair. Supports multiple
/// `on scanline(N)` handlers per state — they fire in ascending

View file

@ -662,12 +662,14 @@ 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_p2_input = has_label(user_code, "__p2_input_used");
all_instructions.extend(runtime::gen_nmi(runtime::NmiOptions {
has_ppu_updates,
has_audio,
debug_mode,
has_sprite_cycle,
has_oam,
has_p2_input,
}));
// IRQ handler

View file

@ -386,6 +386,12 @@ pub struct NmiOptions {
/// so the DMA is ~520 wasted cycles per NMI. Skipping it saves
/// those cycles plus 9 bytes of NMI code.
pub has_oam: bool,
/// When false, drop the three instructions that shift `$4017`
/// (JOY2) into `ZP_INPUT_P2` from the NMI's 8-iteration input
/// loop. Single-player programs save ~6 bytes of code and ~30
/// cycles per frame (a `LDA abs`, an `LSR A`, and a `ROL zp`
/// running 8 times).
pub has_p2_input: bool,
}
#[must_use]
@ -396,6 +402,7 @@ pub fn gen_nmi(opts: NmiOptions) -> Vec<Instruction> {
debug_mode,
has_sprite_cycle,
has_oam,
has_p2_input,
} = opts;
let mut out = Vec::new();
@ -506,16 +513,20 @@ pub fn gen_nmi(opts: NmiOptions) -> Vec<Instruction> {
out.push(Instruction::new(STA, AM::Absolute(JOY1)));
// Read 8 button bits from controller 1 ($4016) into ZP_INPUT_P1
// and 8 button bits from controller 2 ($4017) into ZP_INPUT_P2
// simultaneously — shift each port's carry into its ZP byte.
// — 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)));
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)));
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,