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

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;
}
}