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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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)));
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,

View file

@ -1 +1 @@
dceef796 132084
b52a0b1e 132084

View file

@ -1 +1 @@
a9a829f7 132084
9387feb3 132084

View file

@ -1 +1 @@
be86c2a2 132084
752f9eee 132084

View file

@ -1 +1 @@
c9c2333d 132084
83c0eee4 132084

View file

@ -1 +1 @@
7be9372c 132084
fd72f4a8 132084

View file

@ -1 +1 @@
b7b5b9a0 132084
51b560a7 132084