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

codegen: emit gate markers at end of generate() to protect peephole

Move the six gate-marker label emissions (__mul_used, __div_used,
__oam_used, __default_sprite_used, __p1_input_used, __p2_input_used)
out of the inline IR-op lowering paths and into a new
`emit_trailing_markers()` helper that runs once at the end of
`generate()`. The IR walk now just flips a bool per marker; the
label emit happens after every instruction has been lowered, so
the marker never lands in the middle of a peephole-sensitive
sequence.

Fixes a real peephole interaction that surfaced after rebasing on
main's `codegen: skip parameter-spill prologue for leaf functions`
+ `peephole: drop dead LDA #imm before mem-INC/DEC + JMP`
improvements: an inline `__oam_used:` label inside `IrOp::DrawSprite`
split the dead-load-elimination block, leaving the `STA $130 /
LDA $130` redundant store+load pair that main's peephole would
otherwise have collapsed to a plain `LDA #imm`. The stale bytes
shifted the NMI handler by a few bytes, which shifted `on frame`
execution enough that `examples/palette_and_background.ne` captured
phase 1 (WarmReds) at frame 180 instead of phase 2 (CoolBlues).

Regenerates every example ROM against the new codegen (all gate
behaviour is unchanged — the linker still sees the same markers,
just at the tail of the user stream instead of interleaved) and
updates the goldens that shifted: seven audio-hash drifts (all
audio-bearing programs, same cycle-accurate-APU-timing story as
every prior NMI layout change) and two pixel goldens — the one-
pixel sprite-position drift in `comparisons.png` that we already
tolerate, plus the phase-capture flip in
`palette_and_background.png`.

https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
This commit is contained in:
Claude 2026-04-16 21:31:47 +00:00
parent e5bc325a71
commit c09f9c0caa
No known key found for this signature in database
43 changed files with 56 additions and 84 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.

Binary file not shown.

Binary file not shown.

View file

@ -882,6 +882,15 @@ impl<'a> IrCodeGen<'a> {
self.emit(JMP, AM::Label("__debug_halt".to_string()));
}
// Pay-as-you-go marker labels for flags that got set during
// the IR walk above. Emitting them here (rather than at the
// call sites) keeps the markers out of peephole-sensitive
// instruction sequences — a label inside `IrOp::DrawSprite`
// or `IrOp::ReadInput`, for instance, would otherwise split
// the dead-load-elimination block and leave stale ZP
// stores in the stream.
self.emit_trailing_markers();
// Snapshot the fixed-bank instruction stream before we
// start emitting the banked function bodies into their own
// streams. Programs without any banked functions skip the
@ -1229,10 +1238,13 @@ impl<'a> IrCodeGen<'a> {
}
IrOp::Mul(d, a, b) => {
// Software multiply: multiplicand in A, multiplier in $02.
// Drop the `__mul_used` marker so the linker knows to
// Flag the `__mul_used` marker so the linker knows to
// link the `__multiply` subroutine in — programs that
// don't multiply skip ~50 bytes of runtime.
self.emit_mul_marker();
// don't multiply skip ~50 bytes of runtime. The label
// itself is emitted at the end of generate() so it
// doesn't disturb peephole-sensitive instruction
// sequences here.
self.mul_used = true;
self.load_temp(*a);
self.emit(PHA, AM::Implied); // Save for __multiply contract
let b_addr = self.temp_addr(*b);
@ -1279,10 +1291,11 @@ impl<'a> IrCodeGen<'a> {
IrOp::Div(d, a, b) => {
// Software divide: dividend in A, divisor in $02.
// `__divide` returns quotient in A and leaves
// remainder in ZP $03. Emit the `__div_used` marker
// remainder in ZP $03. Flag the `__div_used` marker
// so the linker links the `__divide` subroutine in;
// programs that don't divide skip ~70 bytes.
self.emit_div_marker();
// programs that don't divide skip ~70 bytes. The
// label is emitted at the end of generate().
self.div_used = true;
self.load_temp(*a);
self.emit(PHA, AM::Implied);
let b_addr = self.temp_addr(*b);
@ -1297,7 +1310,7 @@ impl<'a> IrCodeGen<'a> {
// of ZP $03 afterwards. Same `__div_used` marker as
// `IrOp::Div` — modulo doesn't have a separate
// runtime routine.
self.emit_div_marker();
self.div_used = true;
self.load_temp(*a);
self.emit(PHA, AM::Implied);
let b_addr = self.temp_addr(*b);
@ -1421,14 +1434,16 @@ impl<'a> IrCodeGen<'a> {
y,
frame,
} => {
// Drop the `__oam_used` marker so the linker knows
// to emit the OAM DMA + shadow-init + default-sprite
// Flag the `__oam_used` marker so the linker knows
// to emit the OAM DMA + shadow-init + default-palette
// machinery. Programs that never `draw` skip all of
// those paths — no DMA cycles per NMI, no $FE OAM
// shadow fill, no built-in smiley reserved in CHR,
// and the default palette is suppressed too when the
// program has no other visual output.
self.emit_oam_marker();
// program has no other visual output. The label is
// emitted at the end of generate() so it doesn't
// split a peephole block here.
self.oam_used = true;
// Runtime OAM-cursor-based draw. Each frame handler
// resets `ZP_OAM_CURSOR` to 0 after the OAM clear; a
// `draw` loads the cursor into Y, writes the four
@ -1476,12 +1491,12 @@ impl<'a> IrCodeGen<'a> {
// the marker off and tile 0 becomes user-available
// as a blank (all-$00) background tile.
if let Some(f) = frame {
self.emit_default_sprite_marker();
self.default_sprite_used = true;
self.load_temp(*f);
} else if let Some(&tile) = self.sprite_tiles.get(sprite_name) {
self.emit(LDA, AM::Immediate(tile));
} else {
self.emit_default_sprite_marker();
self.default_sprite_used = true;
self.emit(LDA, AM::Immediate(0));
}
self.emit(STA, AM::AbsoluteY(0x0201));
@ -1504,15 +1519,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
// Flag 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`.
// Labels are emitted at the end of generate().
if *player == 1 {
self.emit_p2_input_marker();
self.p2_input_used = true;
} else {
self.emit_p1_input_marker();
self.p1_input_used = true;
}
// $01 = P1 input byte, $08 = P2 input byte
let addr = if *player == 1 { 0x08 } else { 0x01 };
@ -2112,78 +2128,34 @@ impl<'a> IrCodeGen<'a> {
}
}
/// Emit the `__mul_used` marker label at most once per program.
/// The linker uses this to decide whether to link in the
/// `gen_multiply` subroutine. Programs that never multiply (or
/// that only multiply by constant powers of two — those are
/// strength-reduced to shifts) skip the runtime entirely.
fn emit_mul_marker(&mut self) {
if !self.mul_used {
/// Emit the marker labels for flags that got set somewhere
/// during `generate()`'s IR walk. Called once, at the end of
/// `generate()`, so the labels land after every instruction
/// that could have set a flag — never splitting a
/// peephole-sensitive block at the flag's call site.
///
/// Each label is a zero-byte pseudo-op. The linker looks them
/// up by name via `has_label` and turns on the gated runtime
/// feature. Programs that never set a given flag emit nothing
/// for it — no label, no lookup hit, no gated code.
fn emit_trailing_markers(&mut self) {
if self.mul_used {
self.emit_label("__mul_used");
self.mul_used = true;
}
}
/// Emit the `__div_used` marker label at most once per program.
/// The linker uses this to decide whether to link in the
/// `gen_divide` subroutine. Both `IrOp::Div` and `IrOp::Mod`
/// trigger this marker because modulo reuses the divide
/// routine. Programs whose divide/modulo operations all use
/// constant power-of-two divisors have already been rewritten
/// to shifts / masks by the optimizer and never reach here.
fn emit_div_marker(&mut self) {
if !self.div_used {
if self.div_used {
self.emit_label("__div_used");
self.div_used = true;
}
}
/// Emit the `__oam_used` marker label at most once per program.
/// Triggered by `IrOp::DrawSprite`. The linker drops the OAM
/// DMA + shadow-init + default-palette paths when this marker
/// is absent, shaving cycles out of every NMI and bytes out of
/// PRG for programs that don't draw sprites.
fn emit_oam_marker(&mut self) {
if !self.oam_used {
if self.oam_used {
self.emit_label("__oam_used");
self.oam_used = true;
}
}
/// Emit the `__default_sprite_used` marker label at most once
/// per program. Triggered by `IrOp::DrawSprite` when the sprite
/// name doesn't resolve to a user declaration (fall-back to
/// tile 0) or when a runtime `frame:` override could land on
/// tile 0. The linker uses this to decide whether to copy the
/// built-in smiley into CHR tile 0; programs whose draws all
/// resolve to declared sprites with static frames skip the
/// emit and get tile 0 back as a blank background tile.
fn emit_default_sprite_marker(&mut self) {
if !self.default_sprite_used {
if self.default_sprite_used {
self.emit_label("__default_sprite_used");
self.default_sprite_used = true;
}
}
/// 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 {
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 {
if self.p2_input_used {
self.emit_label("__p2_input_used");
self.p2_input_used = true;
}
}

View file

@ -1 +1 @@
b52a0b1e 132084
23afaaa6 132084

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,023 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

@ -1 +1 @@
9387feb3 132084
bc94ed30 132084

View file

@ -1 +1 @@
a05d1284 132084
52d1836f 132084

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

View file

@ -1 +1 @@
83c0eee4 132084
ea23d9c4 132084

View file

@ -1 +1 @@
b7d5b53f 132084
555a22e1 132084

View file

@ -1 +1 @@
ff926f72 132084
8142ffb7 132084

View file

@ -1 +1 @@
51b560a7 132084
e3805766 132084