1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 17:06:04 +00:00
nescript/src/runtime/mod.rs
Claude 5e5bed39a5
sprite-per-scanline: add cycle_sprites runtime flicker + debug telemetry
W0109 (shipped last commit) catches the 8-sprites-per-scanline
hardware limit at compile time for static layouts, but the
dynamic case — enemy formations, projectile clusters, animated
NPCs where coordinates come from variables — was still silent.
This change adds two layers of defense on top of W0109:

Layer 2: `cycle_sprites` runtime flicker intrinsic
  New keyword statement that rotates the OAM DMA start offset
  one slot per call. When called once per `on frame`, the PPU's
  sprite evaluation picks up a different subset of the 12+
  overlapping sprites each frame, so the permanent-dropout
  failure mode becomes visible flicker — the classic NES
  technique used by Gradius, Battletoads, and every shmup.

  Implementation:
    - Lexer keyword `KwCycleSprites` and parser production.
    - AST `Statement::CycleSprites(Span)`.
    - `IrOp::CycleSprites` lowered by the IR pass.
    - Codegen emits `LDA $07EF / CLC / ADC #4 / STA $07EF` with
      natural u8 wrap, plus a one-shot `__sprite_cycle_used`
      marker label the first time it fires.
    - Linker detects the marker and switches `gen_nmi` to the
      cycling variant, which reads the rotating offset from
      `$07EF` into OAM_ADDR before the DMA instead of writing
      a literal 0. Programs that don't call `cycle_sprites`
      skip the marker and get byte-identical ROM output.

Layer 3: debug-mode sprite overflow telemetry
  Mirrors the frame-overrun pair (`debug.frame_overrun_count` /
  `debug.frame_overran`). In debug builds the NMI handler reads
  `$2002` at the top of vblank, masks bit 5 (the PPU's sprite
  overflow flag), and if set bumps a cumulative counter at
  `$07FD` plus a sticky bit at `$07FC`. The sticky bit clears
  on every `wait_frame`.

  New debug builtins:
    - `debug.sprite_overflow_count()` → u8 peek of $07FD
    - `debug.sprite_overflow()` → u8 peek of $07FC (sticky bit)

  The hardware flag has well-known quirks but is correct for
  the overwhelming majority of cases and costs ~15 cycles per
  frame to sample. Release builds emit no overflow-check code
  at all, so the four bytes at `$07EF` / `$07FC`-`$07FD` stay
  free for user allocation.

Related changes:
  - `gen_nmi` now takes an `NmiOptions` struct. Four bool
    parameters tripped clippy's `fn_params_excessive_bools`.
  - CLI `build` now renders analyzer warnings on a successful
    build. Previously warnings were silently dropped unless
    the user also ran `nescript check`, which made W0109
    effectively invisible to CI and local dev alike. Existing
    pre-existing W0103 / W0106 warnings on `coin_cavern`,
    `mmc3_per_state_split`, `sprites_and_palettes` surface
    too — not regressions, just now visible.

New example: `examples/sprite_flicker_demo.ne`
  Draws 12 sprites into a 4-pixel band, W0109 fires at compile
  time with nine labels pointing at the offenders, and a
  `cycle_sprites` call at the end of `on frame` turns the
  hardware dropout into flicker. The committed emulator golden
  captures one frame of the cycling pattern (deterministic).

Tests:
  - `runtime::tests::nmi_debug_mode_samples_sprite_overflow`
  - `runtime::tests::nmi_sprite_cycle_variant_reads_rotating_offset`
  - `ir_codegen::*::debug_sprite_overflow_count_loads_07fd`
  - `ir_codegen::*::debug_sprite_overflow_flag_loads_07fc`
  - `ir_codegen::*::wait_frame_clears_sprite_overflow_sticky_in_debug_mode`
  - `ir_codegen::*::wait_frame_release_does_not_touch_sprite_overflow_sticky`
  - `ir_codegen::*::cycle_sprites_emits_marker_and_add4`
  - `ir_codegen::*::cycle_sprites_marker_dedup_across_multiple_calls`
  - `ir_codegen::*::program_without_cycle_sprites_emits_no_marker`
  - `analyzer::*::accepts_debug_sprite_overflow_builtins`
  - `analyzer::*::rejects_unknown_debug_method_lists_all_four_known_names`
  - `analyzer::*::accepts_cycle_sprites_statement`

Docs: `examples/war/COMPILER_BUGS.md` §4 now describes all three
layers (W0109, `cycle_sprites`, debug telemetry) with reasoning
for when each applies. `README.md` and `examples/README.md` add
the new example to their tables.

All 32 emulator goldens still match — the cycling is opt-in
and programs that don't call `cycle_sprites` or enable debug
mode are byte-identical to the pre-change output.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 22:07:19 +00:00

1821 lines
80 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#[cfg(test)]
mod tests;
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
use crate::parser::ast::{Mapper, Mirroring};
/// PPU register addresses
const PPU_CTRL: u16 = 0x2000;
const PPU_MASK: u16 = 0x2001;
const PPU_STATUS: u16 = 0x2002;
const OAM_ADDR: u16 = 0x2003;
const OAM_DMA: u16 = 0x4014;
const APU_STATUS: u16 = 0x4015;
const JOY1: u16 = 0x4016;
const APU_FRAME: u16 = 0x4017;
/// Zero-page locations used by the runtime.
pub const ZP_FRAME_FLAG: u8 = 0x00;
pub const ZP_INPUT_P1: u8 = 0x01;
pub const ZP_INPUT_P2: u8 = 0x08;
/// Runtime OAM cursor, incremented by 4 on every `draw` inside a
/// frame handler. The IR codegen resets this to 0 after the OAM
/// clear at the top of the handler, so each `draw` writes to the
/// next 4-byte sprite slot regardless of how many loop iterations
/// came before it. At 64 slots the u8 naturally wraps to 0 and
/// the oldest slot gets overwritten — the classic NES flicker
/// fallback.
pub const ZP_OAM_CURSOR: u8 = 0x09;
/// Pulse-1 SFX envelope pointer (2 bytes, lo/hi) — points at the
/// *current* frame's $4000 envelope byte inside the sfx blob. The
/// audio tick reads through this byte, writes to $4000, advances
/// the pointer, and keeps going until it reads a zero sentinel.
pub const ZP_SFX_PTR_LO: u8 = 0x0C;
pub const ZP_SFX_PTR_HI: u8 = 0x0D;
/// Pulse-2 music note-stream pointer (2 bytes, lo/hi) — points at
/// the *current* (pitch, duration) note pair inside the music blob.
pub const ZP_MUSIC_PTR_LO: u8 = 0x0E;
pub const ZP_MUSIC_PTR_HI: u8 = 0x0F;
/// Music base pointer (2 bytes) — start of the currently-loaded
/// track. Used by the loop-back branch when the driver hits the
/// end-of-track sentinel and the header loop flag is set.
pub const ZP_MUSIC_BASE_LO: u8 = 0x05;
pub const ZP_MUSIC_BASE_HI: u8 = 0x06;
/// Music state byte. Bit layout:
/// bit 0: 1 = track is looping, 0 = one-shot
/// bit 1: 1 = music is active (non-zero means "playing")
/// bits 2-5: latched pulse-2 envelope volume 0-15
/// bits 6-7: latched pulse-2 duty
/// Set on `start_music`, cleared (to 0) on `stop_music`. The driver
/// writes a fresh $4004 envelope byte every time it advances to a
/// new note using these bits so held notes don't decay.
pub const ZP_MUSIC_STATE: u8 = 0x07;
/// Pulse-1 SFX countdown — `0` means no sfx is playing.
/// Nonzero means the audio tick should read one envelope byte from
/// `ZP_SFX_PTR` each NMI and write it to $4000. When the tick reads
/// a zero sentinel it mutes pulse 1 and clears this byte.
pub const ZP_SFX_COUNTER: u8 = 0x0A;
/// Pulse-2 music duration countdown — frames remaining on the
/// currently-held music note. When it reaches zero, the tick
/// advances to the next (pitch, duration) pair.
pub const ZP_MUSIC_COUNTER: u8 = 0x0B;
// ── PPU update handshake ──
//
// When a program declares `palette` or `background` blocks the
// analyzer reserves `$11-$17` as runtime state for the vblank-safe
// update path. User code sets these from inside a frame handler
// (via `set_palette` / `load_background`), and the NMI handler
// applies any pending update while the PPU is blanked, then
// clears the flags.
/// Bitfield of pending PPU updates.
/// bit 0 = 1 → palette at `ZP_PENDING_PALETTE_*` is pending
/// bit 1 = 1 → background at `ZP_PENDING_BG_TILES_*` / `_ATTRS_*` is pending
pub const ZP_PPU_UPDATE_FLAGS: u8 = 0x11;
pub const ZP_PENDING_PALETTE_LO: u8 = 0x12;
pub const ZP_PENDING_PALETTE_HI: u8 = 0x13;
pub const ZP_PENDING_BG_TILES_LO: u8 = 0x14;
pub const ZP_PENDING_BG_TILES_HI: u8 = 0x15;
pub const ZP_PENDING_BG_ATTRS_LO: u8 = 0x16;
pub const ZP_PENDING_BG_ATTRS_HI: u8 = 0x17;
// ── Debug instrumentation ──
//
// These slots are only touched by debug-mode ROMs. In release
// builds the analyzer is free to allocate over them.
/// Debug-mode frame-overrun counter. Incremented by the NMI
/// handler whenever it fires while the previous frame's ready
/// flag is still set — which means the main loop didn't consume
/// it, so user code spent more than one vblank-to-vblank window
/// processing the last frame. Read it with `peek(0x07FF)` or
/// `debug.frame_overrun_count()` in user code to see how many
/// overruns have happened since reset, or watch the address in
/// a Mesen memory viewer. Placed at the top of main RAM to
/// minimise the chance of a collision with analyzer-allocated
/// variables (which grow from $0300 upward).
pub const DEBUG_FRAME_OVERRUN_ADDR: u16 = 0x07FF;
/// Debug-mode "did the previous frame overrun" sticky bit. Set
/// to 1 by the NMI handler at the same time as it bumps
/// [`DEBUG_FRAME_OVERRUN_ADDR`], and cleared to 0 by either an
/// explicit `wait_frame` IR op *or* the implicit main-loop
/// flag-clear that runs between every dispatch — so a program
/// whose `on frame { ... }` body has no explicit `wait_frame`
/// still sees a fresh value next NMI. Exposed to user code as
/// `debug.frame_overran()` — a per-frame "did this frame finish
/// in time" predicate suited for `debug.assert(not debug.frame_overran())`
/// guards. Lives one byte below the cumulative counter so the
/// two can be inspected together in a Mesen memory viewer.
pub const DEBUG_FRAME_OVERRUN_FLAG_ADDR: u16 = 0x07FE;
/// Debug-mode cumulative sprite-per-scanline overflow counter.
/// Incremented by the NMI handler once per frame in which the
/// PPU's sprite overflow flag ($2002 bit 5) was set, i.e. any
/// scanline of the just-finished frame had more than 8 sprites
/// on it and the PPU silently dropped the excess. Read with
/// `peek(0x07FD)` or `debug.sprite_overflow_count()`.
///
/// The PPU hardware flag has two well-known quirks — it can
/// occasionally miss the 9th sprite or flag when none actually
/// overflowed — but it's right for the overwhelming majority of
/// cases and is essentially free to sample (one `LDA $2002; AND
/// #$20` at the top of NMI). Pairs with the compile-time W0109
/// warning: W0109 catches layouts knowable at compile time (text,
/// HUD, title screens) and this counter catches the dynamic
/// cases (enemy formations, projectile clusters) during
/// playtesting in debug builds. Release-mode ROMs never touch
/// this slot, so the analyzer is free to allocate over it.
pub const DEBUG_SPRITE_OVERFLOW_COUNT_ADDR: u16 = 0x07FD;
/// Debug-mode "did the previous frame hit the 8-sprites-per-
/// scanline limit" sticky bit. Set by the NMI handler together
/// with [`DEBUG_SPRITE_OVERFLOW_COUNT_ADDR`], and cleared to 0
/// by every `wait_frame` IR op (or the implicit main-loop
/// clear) so user code sees a fresh value every frame.
/// Exposed to user code as `debug.sprite_overflow()`, a
/// per-frame boolean suited for
/// `debug.assert(not debug.sprite_overflow())` guards during
/// playtesting.
pub const DEBUG_SPRITE_OVERFLOW_FLAG_ADDR: u16 = 0x07FC;
/// Runtime sprite-cycling offset. When any program statement
/// emits a `cycle_sprites` call the codegen drops the
/// `__sprite_cycle_used` marker, and the linker builds the
/// cycling variant of the NMI handler: instead of writing 0
/// to `OAM_ADDR` before the OAM DMA, it writes the current value
/// of this byte, which rotates the destination slot of the DMA
/// copy around the 64-slot OAM buffer. `cycle_sprites` adds 4
/// to this byte each call (naturally wrapping at 256 back to 0),
/// moving the copy start by one OAM slot per tick.
///
/// The result is the classic NES "sprite flicker" pattern: a
/// scene with >8 sprites on a scanline drops a different one
/// each frame rather than the same one every frame, so users
/// perceive flicker instead of permanent dropout — vastly
/// better UX because the eye reconstructs the missing pixels
/// from adjacent frames.
///
/// Programs that never use `cycle_sprites` leave this byte at
/// 0 forever and the NMI handler emits the original `LDA #0;
/// STA $2003` sequence, preserving byte-for-byte compatibility
/// with every existing golden ROM.
pub const SPRITE_CYCLE_ADDR: u16 = 0x07EF;
// ── Extra channel state ──
//
// The pulse-1 sfx and pulse-2 music channels live in zero page
// ($00-$0F) where every byte is precious. Adding new channel
// state there would either push user variables back by 6 bytes
// (breaking every existing example's ZP layout) or collide with
// runtime scratch slots. Instead, we park triangle and noise
// state at the very top of main RAM, just below the debug frame
// overrun counter, where analyzer-allocated globals rarely reach
// (they grow from $0300 upward). The few extra cycles per
// absolute access are negligible for a once-per-NMI tick.
//
// The state is only *referenced* by the audio tick when the
// corresponding `has_noise` / `has_triangle` flag is set — so
// programs that don't declare any noise/triangle sfx touch
// these addresses zero times, and the ROM bytes generated for
// an existing audio example are byte-identical to what today's
// compiler produces.
pub const AUDIO_NOISE_PTR_LO: u16 = 0x07F0;
pub const AUDIO_NOISE_PTR_HI: u16 = 0x07F1;
pub const AUDIO_NOISE_COUNTER: u16 = 0x07F2;
pub const AUDIO_TRIANGLE_PTR_LO: u16 = 0x07F3;
pub const AUDIO_TRIANGLE_PTR_HI: u16 = 0x07F4;
pub const AUDIO_TRIANGLE_COUNTER: u16 = 0x07F5;
/// Pulse-1 sfx per-frame pitch envelope pointer. Only populated
/// (and only read by the audio tick) in programs that declare at
/// least one sfx with a varying-pitch `pitch:` array; programs
/// that stick to scalar `pitch:` keep their byte-for-byte
/// pre-pitch-envelope ROM output. The tick treats a zero
/// high-byte as "no pitch update for the currently-playing sfx",
/// which lets a single program mix sfx with and without pitch
/// envelopes.
pub const AUDIO_SFX_PITCH_PTR_LO: u16 = 0x07F6;
pub const AUDIO_SFX_PITCH_PTR_HI: u16 = 0x07F7;
/// Generate the NES hardware initialization sequence.
/// This runs at RESET and sets up the hardware before user code.
pub fn gen_init() -> Vec<Instruction> {
let mut out = Vec::new();
// Disable IRQs and set decimal mode off
out.push(Instruction::implied(SEI));
out.push(Instruction::implied(CLD));
// Disable APU frame counter IRQ
out.push(Instruction::new(LDX, AM::Immediate(0x40)));
out.push(Instruction::new(STX, AM::Absolute(APU_FRAME)));
// Set up stack at $01FF
out.push(Instruction::new(LDX, AM::Immediate(0xFF)));
out.push(Instruction::implied(TXS));
// Disable PPU rendering
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(STA, AM::Absolute(PPU_CTRL)));
out.push(Instruction::new(STA, AM::Absolute(PPU_MASK)));
// Disable DMC IRQs momentarily (will re-enable the square
// channels below so `play`/`start_music` can make sound).
out.push(Instruction::new(STA, AM::Absolute(APU_STATUS)));
// Enable pulse 1 and pulse 2 channels for the minimal audio
// driver. SFX runs on pulse 1, music on pulse 2. We leave
// triangle / noise / DMC disabled — the engine is deliberately
// simple and those channels would go unused anyway.
out.push(Instruction::new(LDA, AM::Immediate(0x03)));
out.push(Instruction::new(STA, AM::Absolute(APU_STATUS)));
// Pre-silence both channels: `$30` on the volume register sets
// constant-volume envelope with volume 0 and halts the length
// counter, which is the canonical "silent but armed" state.
out.push(Instruction::new(LDA, AM::Immediate(0x30)));
out.push(Instruction::new(STA, AM::Absolute(0x4000)));
out.push(Instruction::new(STA, AM::Absolute(0x4004)));
// Clear sweep units so the channel tone doesn't auto-slide.
out.push(Instruction::new(LDA, AM::Immediate(0x08)));
out.push(Instruction::new(STA, AM::Absolute(0x4001)));
out.push(Instruction::new(STA, AM::Absolute(0x4005)));
// Restore the zero we need for the subsequent RAM clear below.
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
// Wait for first vblank
// vblankwait1:
out.push(Instruction::new(NOP, AM::Label("__vblankwait1".into())));
out.push(Instruction::new(BIT, AM::Absolute(PPU_STATUS)));
out.push(Instruction::new(
BPL,
AM::LabelRelative("__vblankwait1".into()),
));
// Clear RAM ($0000-$07FF)
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(LDX, AM::Immediate(0x00)));
out.push(Instruction::new(NOP, AM::Label("__clrmem".into())));
out.push(Instruction::new(STA, AM::AbsoluteX(0x0000)));
out.push(Instruction::new(STA, AM::AbsoluteX(0x0100)));
// OAM shadow: fill with $FE (hide sprites off-screen)
out.push(Instruction::new(LDA, AM::Immediate(0xFE)));
out.push(Instruction::new(STA, AM::AbsoluteX(0x0200)));
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(STA, AM::AbsoluteX(0x0300)));
out.push(Instruction::new(STA, AM::AbsoluteX(0x0400)));
out.push(Instruction::new(STA, AM::AbsoluteX(0x0500)));
out.push(Instruction::new(STA, AM::AbsoluteX(0x0600)));
out.push(Instruction::new(STA, AM::AbsoluteX(0x0700)));
out.push(Instruction::implied(INX));
out.push(Instruction::new(BNE, AM::LabelRelative("__clrmem".into())));
// Wait for second vblank
out.push(Instruction::new(NOP, AM::Label("__vblankwait2".into())));
out.push(Instruction::new(BIT, AM::Absolute(PPU_STATUS)));
out.push(Instruction::new(
BPL,
AM::LabelRelative("__vblankwait2".into()),
));
// Enable NMI so the frame handshake fires every vblank. We
// deliberately leave PPU_MASK at 0 (rendering fully disabled)
// here — the linker splices in palette and background loads
// after this init, and $2007 writes during active rendering
// corrupt their target addresses via the PPU's v-register
// auto-increment glitch. Rendering is enabled by the linker
// *after* all initial VRAM loads complete, via `gen_enable_rendering`.
out.push(Instruction::new(LDA, AM::Immediate(0x80))); // enable NMI
out.push(Instruction::new(STA, AM::Absolute(PPU_CTRL)));
out
}
/// Emit the `PPU_MASK` write that turns on rendering. Called by
/// the linker at the very end of the reset path, after all
/// initial palette / background loads are done, so the initial
/// VRAM writes are never corrupted by a mid-frame `$2007` glitch.
///
/// `show_background` controls whether the background layer is
/// enabled alongside the sprite layer — programs that declare a
/// `background` block want both, programs that don't can skip
/// the background bit to match the pre-fix behaviour.
#[must_use]
pub fn gen_enable_rendering(show_background: bool) -> Vec<Instruction> {
// $1E = show bg + sprites + left-8-px for both
// $10 = show sprites only (no bg)
let mask = if show_background { 0x1E } else { 0x10 };
vec![
Instruction::new(LDA, AM::Immediate(mask)),
Instruction::new(STA, AM::Absolute(PPU_MASK)),
]
}
/// Generate the NMI handler.
/// Called every vblank by the NES hardware.
///
/// `has_ppu_updates` controls whether the handler runs the
/// palette / nametable update helper. When false, the handler skips
/// that block entirely so programs that never call `set_palette` /
/// `load_background` pay zero cycles or bytes for the feature.
///
/// `has_audio` controls whether the handler calls the audio tick.
/// When true, the JSR to `__audio_tick` is emitted *after* the
/// register and scratch-slot saves, so the tick is free to trash
/// A/X/Y and the mul/state ZP scratch ($02/$03) without corrupting
/// the user's main-loop state. Placing the JSR outside the
/// save/restore window used to silently clobber `ZP_CURRENT_STATE`
/// whenever a music note was played (the tick's period-table
/// lookup stashes the table's high byte into $03).
///
/// `debug_mode` enables frame-overrun detection: before touching
/// the frame-ready flag, the handler checks whether it's already
/// set — if it is, the previous frame's main-loop work never
/// finished (i.e. the program ran over its vblank budget) and
/// the handler bumps the counter at
/// [`DEBUG_FRAME_OVERRUN_ADDR`]. Release-mode ROMs never call
/// this with `debug_mode=true`, so the counter slot stays free
/// for user allocation.
///
/// `has_sprite_cycle` selects between two OAM DMA setup paths:
/// when false the NMI writes a literal 0 to `$2003` before
/// triggering the DMA (classic behaviour, byte-identical to
/// every pre-cycling ROM), and when true it reads the rotating
/// offset byte from [`SPRITE_CYCLE_ADDR`] so each frame's DMA
/// lands in a different slot of the PPU's OAM buffer. The
/// per-frame increment is emitted at the `cycle_sprites` call
/// site, not here, so programs can choose to cycle every frame
/// (one call in `on frame`) or every Nth frame.
/// Compile-time switches that pick which NMI-handler variant
/// the runtime emits. Each bool either inlines or skips a
/// self-contained block inside [`gen_nmi`]; programs that don't
/// opt into a feature pay zero ROM/cycle cost for it. Grouped
/// into a struct rather than passed as individual parameters
/// to avoid tripping the clippy `fn_params_excessive_bools`
/// lint and to give future additions (another marker-label-
/// triggered NMI block) an obvious extension point.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Copy, Default)]
pub struct NmiOptions {
pub has_ppu_updates: bool,
pub has_audio: bool,
pub debug_mode: bool,
pub has_sprite_cycle: bool,
}
#[must_use]
pub fn gen_nmi(opts: NmiOptions) -> Vec<Instruction> {
let NmiOptions {
has_ppu_updates,
has_audio,
debug_mode,
has_sprite_cycle,
} = opts;
let mut out = Vec::new();
// Save registers
out.push(Instruction::implied(PHA));
out.push(Instruction::implied(TXA));
out.push(Instruction::implied(PHA));
out.push(Instruction::implied(TYA));
out.push(Instruction::implied(PHA));
// Save the multiply/divide scratch slots ($02/$03). $03 doubles
// as `ZP_CURRENT_STATE` for the state dispatch, and user code
// mid-multiply/divide has both slots live; preserving them here
// keeps the invariant that NMI never clobbers user-visible ZP
// state.
out.push(Instruction::new(LDA, AM::ZeroPage(0x02)));
out.push(Instruction::implied(PHA));
out.push(Instruction::new(LDA, AM::ZeroPage(0x03)));
out.push(Instruction::implied(PHA));
// Debug-mode sprite overflow sampling. The PPU sets bit 5 of
// $2002 when its sprite evaluation hits more than 8 in-range
// sprites on any scanline of the frame it just finished
// rendering. NMI fires at the start of vblank, right after
// that rendering ends, so this is the exact moment the flag
// is valid for "did the just-finished frame overflow". The
// flag is cleared by the PPU at dot 1 of the pre-render line
// (261), which is *before* the next NMI, so each NMI sees a
// flag that reflects only the frame it follows.
//
// Reading $2002 has the side effects of (a) clearing the
// vblank latch in bit 7 and (b) resetting the $2005/$2006
// write-toggle. Both are harmless here: NMI was already
// taken, and `gen_ppu_update_apply` below always opens its
// own $2006 address with a fresh pair of writes so the reset
// toggle doesn't confuse it.
//
// The counter/sticky pair mirrors the frame-overrun pattern
// at $07FE/$07FF. Release builds don't emit this block at
// all, so the two bytes at $07FC/$07FD stay free for the
// analyzer to allocate over.
if debug_mode {
out.push(Instruction::new(LDA, AM::Absolute(PPU_STATUS)));
out.push(Instruction::new(AND, AM::Immediate(0x20)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__debug_no_sprite_ovf".into()),
));
out.push(Instruction::new(
INC,
AM::Absolute(DEBUG_SPRITE_OVERFLOW_COUNT_ADDR),
));
// Reuse A, which still holds 0x20. Nonzero is enough for
// the sticky bit; the exact value doesn't matter because
// user code reads it as a boolean via
// `debug.sprite_overflow()`.
out.push(Instruction::new(
STA,
AM::Absolute(DEBUG_SPRITE_OVERFLOW_FLAG_ADDR),
));
out.push(Instruction::new(
NOP,
AM::Label("__debug_no_sprite_ovf".into()),
));
}
// Run the audio driver's per-frame tick *after* the saves so it
// can freely reuse A/X/Y and the $02/$03 scratch slots without
// corrupting anything the main loop cares about. Programs that
// never touch audio skip this splice entirely — no ROM cost.
if has_audio {
out.push(Instruction::new(JSR, AM::Label("__audio_tick".into())));
}
// OAM DMA — transfer sprite data from $0200. Programs that
// don't use `cycle_sprites` get the classic fixed-offset
// path (LDA #0); programs that opt in get the rotating
// offset read from SPRITE_CYCLE_ADDR. Both variants write
// the same low byte ($02) for the DMA source page.
if has_sprite_cycle {
out.push(Instruction::new(LDA, AM::Absolute(SPRITE_CYCLE_ADDR)));
} else {
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
}
out.push(Instruction::new(STA, AM::Absolute(OAM_ADDR)));
out.push(Instruction::new(LDA, AM::Immediate(0x02)));
out.push(Instruction::new(STA, AM::Absolute(OAM_DMA)));
// PPU updates: check the flags byte, apply any pending palette
// or background write. Runs inside vblank where $2006/$2007
// writes are safe. Gated on `has_ppu_updates` so programs that
// never touch palette or background decls skip this entirely.
if has_ppu_updates {
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)));
// 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.
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)));
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
// new NMI, the main loop never reached its wait_frame since
// the previous vblank — i.e. the frame overran. Bump a
// counter at `DEBUG_FRAME_OVERRUN_ADDR` in that case so user
// code can `peek(0x07FF)` to see how many overruns have
// happened. The check is gated on `debug_mode` so release
// builds emit nothing here.
if debug_mode {
// Read the previous flag. If zero, skip the bump.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_FRAME_FLAG)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__debug_no_overrun".into()),
));
out.push(Instruction::new(
INC,
AM::Absolute(DEBUG_FRAME_OVERRUN_ADDR),
));
// Set the per-frame sticky bit. It stays set until the
// next `wait_frame` clears it, so a single
// `debug.assert(!debug.frame_overran())` guard at the top
// of `on frame { ... }` catches any miss in the previous
// window.
out.push(Instruction::new(LDA, AM::Immediate(0x01)));
out.push(Instruction::new(
STA,
AM::Absolute(DEBUG_FRAME_OVERRUN_FLAG_ADDR),
));
out.push(Instruction::new(
NOP,
AM::Label("__debug_no_overrun".into()),
));
}
// Set frame-ready flag
out.push(Instruction::new(LDA, AM::Immediate(0x01)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_FRAME_FLAG)));
// Restore the mul/state scratch slots ($03 then $02, reverse
// order of the PHA pushes above).
out.push(Instruction::implied(PLA));
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
out.push(Instruction::implied(PLA));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
// Restore registers
out.push(Instruction::implied(PLA));
out.push(Instruction::implied(TAY));
out.push(Instruction::implied(PLA));
out.push(Instruction::implied(TAX));
out.push(Instruction::implied(PLA));
// Return from interrupt
out.push(Instruction::implied(RTI));
out
}
/// Generate the IRQ handler (just RTI for now).
pub fn gen_irq() -> Vec<Instruction> {
vec![Instruction::implied(RTI)]
}
/// Generate the in-NMI PPU update helper. Checks
/// [`ZP_PPU_UPDATE_FLAGS`] and, if any bit is set, copies the
/// corresponding blob from PRG ROM to PPU RAM via `$2006`/`$2007`.
/// Safe because the NMI fires at the start of vblank, giving
/// ~2273 CPU cycles of safe PPU write time — enough for a full
/// palette (32 bytes, ~200 cycles) plus a full nametable
/// (1024 bytes, ~6500 cycles; this doesn't fit in a single frame
/// so big updates should be staged by the caller).
///
/// For simplicity and to keep the NMI bounded, this helper writes
/// the palette first and the nametable second, and only one of
/// each can be pending at a time. If a nametable write is larger
/// than vblank allows the program is responsible for either
/// keeping rendering disabled or splitting the update.
///
/// The helper clears the pending flag only for updates it actually
/// applied, so if a program ever queues a palette and a nametable
/// in the same frame both land on the same NMI.
fn gen_ppu_update_apply() -> Vec<Instruction> {
let mut out = Vec::new();
// Read flags. If zero, jump straight to the done label.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_PPU_UPDATE_FLAGS)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__ppu_update_done".into()),
));
// ── palette update (bit 0) ────────────────────────────────
// Check bit 0; if clear, skip to background.
out.push(Instruction::new(AND, AM::Immediate(0x01)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__ppu_update_no_palette".into()),
));
// Set PPU addr to $3F00.
out.push(Instruction::new(LDA, AM::Absolute(PPU_STATUS)));
out.push(Instruction::new(LDA, AM::Immediate(0x3F)));
out.push(Instruction::new(STA, AM::Absolute(0x2006)));
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(STA, AM::Absolute(0x2006)));
// Loop: write 32 bytes via `LDA (zp),Y` indirect-indexed from
// the pending palette pointer at $12/$13.
out.push(Instruction::new(LDY, AM::Immediate(0x00)));
out.push(Instruction::new(NOP, AM::Label("__ppu_pal_loop".into())));
out.push(Instruction::new(LDA, AM::IndirectY(ZP_PENDING_PALETTE_LO)));
out.push(Instruction::new(STA, AM::Absolute(0x2007)));
out.push(Instruction::implied(INY));
out.push(Instruction::new(CPY, AM::Immediate(32)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__ppu_pal_loop".into()),
));
out.push(Instruction::new(
NOP,
AM::Label("__ppu_update_no_palette".into()),
));
// ── background update (bit 1) ────────────────────────────
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_PPU_UPDATE_FLAGS)));
out.push(Instruction::new(AND, AM::Immediate(0x02)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__ppu_update_no_bg".into()),
));
// Nametable 0 starts at $2000.
out.push(Instruction::new(LDA, AM::Absolute(PPU_STATUS)));
out.push(Instruction::new(LDA, AM::Immediate(0x20)));
out.push(Instruction::new(STA, AM::Absolute(0x2006)));
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(STA, AM::Absolute(0x2006)));
// Write 960 bytes as 4 loops of 240 (so Y fits in u8) — simpler
// to write as an outer X counter across 4 × 240-byte pages.
// X = 4 pages to go
out.push(Instruction::new(LDX, AM::Immediate(4)));
out.push(Instruction::new(NOP, AM::Label("__ppu_bg_outer".into())));
out.push(Instruction::new(LDY, AM::Immediate(0x00)));
out.push(Instruction::new(NOP, AM::Label("__ppu_bg_inner".into())));
out.push(Instruction::new(LDA, AM::IndirectY(ZP_PENDING_BG_TILES_LO)));
out.push(Instruction::new(STA, AM::Absolute(0x2007)));
out.push(Instruction::implied(INY));
out.push(Instruction::new(CPY, AM::Immediate(240)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__ppu_bg_inner".into()),
));
// After each 240-byte block, bump the pointer by 240 so the
// next block reads from the following chunk of the blob.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_PENDING_BG_TILES_LO)));
out.push(Instruction::new(CLC, AM::Implied));
out.push(Instruction::new(ADC, AM::Immediate(240)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_PENDING_BG_TILES_LO)));
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_PENDING_BG_TILES_HI)));
out.push(Instruction::new(ADC, AM::Immediate(0)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_PENDING_BG_TILES_HI)));
out.push(Instruction::implied(DEX));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__ppu_bg_outer".into()),
));
// Now write the 64-byte attribute table (at $23C0 — right after
// the nametable we just wrote). The PPU auto-increment sits at
// $23C0 already since we wrote exactly 960 bytes after $2000.
out.push(Instruction::new(LDY, AM::Immediate(0x00)));
out.push(Instruction::new(
NOP,
AM::Label("__ppu_bg_attr_loop".into()),
));
out.push(Instruction::new(LDA, AM::IndirectY(ZP_PENDING_BG_ATTRS_LO)));
out.push(Instruction::new(STA, AM::Absolute(0x2007)));
out.push(Instruction::implied(INY));
out.push(Instruction::new(CPY, AM::Immediate(64)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__ppu_bg_attr_loop".into()),
));
out.push(Instruction::new(
NOP,
AM::Label("__ppu_update_no_bg".into()),
));
// Clear all pending flags. Programs re-queue every frame if
// they want repeating updates.
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_PPU_UPDATE_FLAGS)));
out.push(Instruction::new(NOP, AM::Label("__ppu_update_done".into())));
out
}
/// Emit a reset-time write of a 32-byte palette blob (referenced
/// by label) to PPU `$3F00-$3F1F`. Rendering must be disabled
/// when this runs (it is, between `gen_init` and the linker's PPU
/// rendering-enable step). Uses the scratch ZP slots `$02/$03` to
/// hold the indirect pointer — safe because nothing else runs
/// between `gen_init` and user code.
#[must_use]
pub fn gen_initial_palette_load(label: &str) -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(LDA, AM::Absolute(PPU_STATUS))); // reset latch
out.push(Instruction::new(LDA, AM::Immediate(0x3F)));
out.push(Instruction::new(STA, AM::Absolute(0x2006)));
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(STA, AM::Absolute(0x2006)));
// Stash the palette label into scratch ZP for indirect LDA.
out.push(Instruction::new(LDA, AM::SymbolLo(label.to_string())));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
out.push(Instruction::new(LDA, AM::SymbolHi(label.to_string())));
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
out.push(Instruction::new(LDY, AM::Immediate(0x00)));
let loop_label = format!("__init_pal_loop_{label}");
out.push(Instruction::new(NOP, AM::Label(loop_label.clone())));
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
out.push(Instruction::new(STA, AM::Absolute(0x2007)));
out.push(Instruction::implied(INY));
out.push(Instruction::new(CPY, AM::Immediate(32)));
out.push(Instruction::new(BNE, AM::LabelRelative(loop_label)));
out
}
/// Emit a reset-time write of a 960-byte nametable + 64-byte
/// attribute table blob to nametable 0 (`$2000-$23FF`). Rendering
/// must be disabled when this runs. The caller passes the label of
/// the tiles blob and the label of the attribute blob separately —
/// the linker emits them as adjacent data blocks so they can be
/// resolved independently.
#[must_use]
pub fn gen_initial_background_load(tiles_label: &str, attrs_label: &str) -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(LDA, AM::Absolute(PPU_STATUS)));
out.push(Instruction::new(LDA, AM::Immediate(0x20)));
out.push(Instruction::new(STA, AM::Absolute(0x2006)));
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(STA, AM::Absolute(0x2006)));
// Write 960 bytes of tile data as 4 × 240 using two nested
// counters. We stash the outer page index in a scratch ZP slot
// because X is too small to index a 960-byte range directly.
out.push(Instruction::new(LDX, AM::Immediate(0x00)));
let page_loop = format!("__init_bg_page_{tiles_label}");
let inner_loop = format!("__init_bg_inner_{tiles_label}");
out.push(Instruction::new(NOP, AM::Label(page_loop.clone())));
// Per-page offset: X*240. Computed via Y and clamped at 240.
out.push(Instruction::new(LDY, AM::Immediate(0x00)));
out.push(Instruction::new(NOP, AM::Label(inner_loop.clone())));
// Fetch byte at blob[X*240 + Y]. We materialize the effective
// absolute address by unrolling 4 separate LDA Absolute,Y
// instructions, one per page, dispatched on X.
// For simplicity and correctness we take the slower path:
// compute (blob + X*240) as a ZP pointer and read via
// `LDA (zp),Y`.
// ZP scratch at $02/$03 (same slots used by the multiply/divide
// contract; gen_init runs before any user code so they're free).
out.push(Instruction::new(LDA, AM::SymbolLo(tiles_label.to_string())));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
out.push(Instruction::new(LDA, AM::SymbolHi(tiles_label.to_string())));
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
// Add X*240 to the low byte (high byte carries via ADC).
// Actually — to keep this simple, we instead track bytes
// remaining as a 16-bit counter and use a generic LDA (ZP),Y
// loop. Rewrite the routine as a flat byte-counted loop.
// (Undo the per-page setup above by rebuilding the output
// vector from scratch.)
out.clear();
out.push(Instruction::new(LDA, AM::Absolute(PPU_STATUS)));
out.push(Instruction::new(LDA, AM::Immediate(0x20)));
out.push(Instruction::new(STA, AM::Absolute(0x2006)));
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(STA, AM::Absolute(0x2006)));
// Load tile blob pointer into $02/$03 scratch slots.
out.push(Instruction::new(LDA, AM::SymbolLo(tiles_label.to_string())));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
out.push(Instruction::new(LDA, AM::SymbolHi(tiles_label.to_string())));
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
// 4 pages × 240 bytes each = 960 bytes total.
out.push(Instruction::new(LDX, AM::Immediate(4)));
let outer = format!("__init_bg_outer_{tiles_label}");
let inner = format!("__init_bg_inner_{tiles_label}");
out.push(Instruction::new(NOP, AM::Label(outer.clone())));
out.push(Instruction::new(LDY, AM::Immediate(0x00)));
out.push(Instruction::new(NOP, AM::Label(inner.clone())));
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
out.push(Instruction::new(STA, AM::Absolute(0x2007)));
out.push(Instruction::implied(INY));
out.push(Instruction::new(CPY, AM::Immediate(240)));
out.push(Instruction::new(BNE, AM::LabelRelative(inner)));
// Advance pointer by 240.
out.push(Instruction::new(LDA, AM::ZeroPage(0x02)));
out.push(Instruction::new(CLC, AM::Implied));
out.push(Instruction::new(ADC, AM::Immediate(240)));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
out.push(Instruction::new(LDA, AM::ZeroPage(0x03)));
out.push(Instruction::new(ADC, AM::Immediate(0)));
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
out.push(Instruction::implied(DEX));
out.push(Instruction::new(BNE, AM::LabelRelative(outer)));
// Now the 64 attribute bytes land at $23C0 — the PPU auto-
// increment is already there after the 960 tile writes.
out.push(Instruction::new(LDA, AM::SymbolLo(attrs_label.to_string())));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
out.push(Instruction::new(LDA, AM::SymbolHi(attrs_label.to_string())));
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
out.push(Instruction::new(LDY, AM::Immediate(0x00)));
let attr_loop = format!("__init_bg_attr_{attrs_label}");
out.push(Instruction::new(NOP, AM::Label(attr_loop.clone())));
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
out.push(Instruction::new(STA, AM::Absolute(0x2007)));
out.push(Instruction::implied(INY));
out.push(Instruction::new(CPY, AM::Immediate(64)));
out.push(Instruction::new(BNE, AM::LabelRelative(attr_loop)));
out
}
/// Zero-page locations used by multiply/divide routines.
const ZP_MUL_OPERAND: u8 = 0x02;
const ZP_MUL_RESULT_HI: u8 = 0x03;
const ZP_DIV_DIVISOR: u8 = 0x02;
const ZP_DIV_REMAINDER: u8 = 0x03;
/// Generate 8x8 -> 16 software multiply routine.
///
/// Input: A = multiplicand, zero-page $02 = multiplier
/// Output: A = result low byte, $03 = result high byte
///
/// Algorithm: shift-and-add. For each bit of the multiplier, if set,
/// add the (shifted) multiplicand to the result.
pub fn gen_multiply() -> Vec<Instruction> {
let mut out = Vec::new();
// Label for the subroutine entry
out.push(Instruction::new(NOP, AM::Label("__multiply".into())));
// Store multiplicand in $04 (working copy)
out.push(Instruction::new(STA, AM::ZeroPage(0x04)));
// Clear result: A (low) and $03 (high)
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUL_RESULT_HI)));
// Loop counter: 8 bits
out.push(Instruction::new(LDX, AM::Immediate(0x08)));
// __mul_loop:
out.push(Instruction::new(NOP, AM::Label("__mul_loop".into())));
// Shift multiplier right, check carry (current bit)
out.push(Instruction::new(LSR, AM::ZeroPage(ZP_MUL_OPERAND)));
out.push(Instruction::new(
BCC,
AM::LabelRelative("__mul_no_add".into()),
));
// Carry set: add multiplicand to result
// Add low byte
out.push(Instruction::implied(CLC));
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUL_RESULT_HI)));
out.push(Instruction::new(ADC, AM::ZeroPage(0x04)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUL_RESULT_HI)));
// __mul_no_add:
out.push(Instruction::new(NOP, AM::Label("__mul_no_add".into())));
// Shift multiplicand left (double it) for next bit position
out.push(Instruction::new(ASL, AM::ZeroPage(0x04)));
// Decrement counter
out.push(Instruction::implied(DEX));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__mul_loop".into()),
));
// Load low byte of result into A
// For 8-bit result, just use the high byte accumulation
// (since we shifted the multiplicand left, result is in $03)
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUL_RESULT_HI)));
out.push(Instruction::implied(RTS));
out
}
/// Generate the per-NMI audio tick. This is the heart of the audio
/// driver — it walks both the SFX envelope and the music note stream
/// every frame and writes the resulting APU register values.
///
/// The linker splices a `JSR __audio_tick` into the NMI handler
/// whenever user code contains any audio op (detected by the
/// `__audio_used` marker label), so programs that never call
/// `play`/`start_music`/`stop_music` pay zero ROM or cycle cost.
///
/// ## SFX channel (pulse 1)
///
/// State:
/// - `ZP_SFX_COUNTER` — nonzero while an sfx is playing
/// - `ZP_SFX_PTR_LO/HI` — pointer into the current sfx blob,
/// advanced one byte per frame
///
/// Each frame: if the counter is nonzero, read one byte through the
/// pointer, write it to `$4000`, and advance the pointer. A zero
/// byte is the sentinel; on it the driver mutes pulse 1 and clears
/// the counter.
///
/// ## Music channel (pulse 2)
///
/// State:
/// - `ZP_MUSIC_COUNTER` — frames remaining on the current note
/// - `ZP_MUSIC_STATE` — bit 1 set = active; bits encode duty/volume/loop
/// - `ZP_MUSIC_PTR_LO/HI` — pointer to the next (pitch,dur) pair
/// - `ZP_MUSIC_BASE_LO/HI` — loop-back start of the current track
///
/// Each frame: if the state says "active" and the counter is nonzero,
/// decrement the counter and bail. When it hits zero, advance past
/// the current (pitch,dur) pair and read the next one. `0xFF,0xFF`
/// is the end-of-track sentinel; the driver either rewinds to the
/// base pointer (looping tracks) or mutes pulse 2 (one-shot tracks).
///
/// ## Clobbers
///
/// A, X, Y. The NMI handler calls this from inside its own
/// save/restore block so caller registers are safe.
///
/// When `has_noise` / `has_triangle` are set, the driver gains an
/// extra per-channel slot: noise routes envelope bytes to `$400C`
/// and drives `$400E` / `$400F` on trigger; triangle writes linear-
/// counter reload values to `$4008`. These blocks are appended to
/// the tick after the music path so that programs which do not
/// declare any noise or triangle sfx produce byte-identical ROM
/// output — the old pulse-only path emits exactly the same
/// instruction stream as before. The linker decides whether to
/// enable each by scanning for the `__noise_used` and
/// `__triangle_used` marker labels emitted by the IR codegen.
pub fn gen_audio_tick(
has_noise: bool,
has_triangle: bool,
has_sfx_pitch: bool,
) -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(NOP, AM::Label("__audio_tick".into())));
// ── SFX tick ──
// If counter is zero, no sfx is playing; skip.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_SFX_COUNTER)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_sfx_done".into()),
));
// Read next envelope byte via (ZP_SFX_PTR),Y with Y=0.
out.push(Instruction::new(LDY, AM::Immediate(0)));
out.push(Instruction::new(LDA, AM::IndirectY(ZP_SFX_PTR_LO)));
// If it's the zero sentinel, silence pulse 1 and clear state.
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_sfx_write".into()),
));
// Sentinel branch: write mute byte to $4000 and clear counter.
out.push(Instruction::new(LDA, AM::Immediate(0x30)));
out.push(Instruction::new(STA, AM::Absolute(0x4000)));
out.push(Instruction::new(LDA, AM::Immediate(0)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_SFX_COUNTER)));
out.push(Instruction::new(JMP, AM::Label("__audio_sfx_done".into())));
// Non-sentinel branch: write envelope byte to $4000, advance ptr.
out.push(Instruction::new(NOP, AM::Label("__audio_sfx_write".into())));
out.push(Instruction::new(STA, AM::Absolute(0x4000)));
// Advance the 16-bit pointer (lo, hi) by 1.
out.push(Instruction::new(INC, AM::ZeroPage(ZP_SFX_PTR_LO)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_sfx_ptr_ok".into()),
));
out.push(Instruction::new(INC, AM::ZeroPage(ZP_SFX_PTR_HI)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_sfx_ptr_ok".into()),
));
// Optional per-frame pitch update. Only emitted in programs
// that declare at least one varying-pitch sfx, gated on the
// `__sfx_pitch_used` codegen marker. The block reads a byte
// through (AUDIO_SFX_PITCH_PTR),Y, writes it to `$4002`
// (pulse-1 period low), then advances the pointer in
// lockstep with the volume envelope above. A zero high byte
// in the pointer is treated as "no pitch envelope on the
// currently-playing sfx" so a program can mix scalar-pitch
// and varying-pitch sfx without the latter clobbering the
// former when it isn't playing.
//
// The pointer lives in main RAM (no zero-page slot pressure)
// and is copied into ZP scratch $02/$03 for the indirect
// read because the 6502 has no `(abs),Y` mode — the same
// technique used by `gen_noise_tick` / `gen_triangle_tick`.
if has_sfx_pitch {
// Bail out if the high byte is zero — sentinel for "the
// currently-playing sfx has no pitch envelope".
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_sfx_pitch_done".into()),
));
// Stash main-RAM pointer in ZP scratch $02/$03.
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_SFX_PITCH_PTR_LO)));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI)));
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
out.push(Instruction::new(LDY, AM::Immediate(0)));
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
// Zero pitch byte? Treat it as the matching mute sentinel
// (volume envelope's zero will already mute on the next
// tick) and skip the period write so we don't yank pulse-1
// to a 0 period for one frame before muting.
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_sfx_pitch_advance".into()),
));
out.push(Instruction::new(STA, AM::Absolute(0x4002)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_sfx_pitch_advance".into()),
));
// Advance the main-RAM pointer 1 byte.
out.push(Instruction::new(INC, AM::Absolute(AUDIO_SFX_PITCH_PTR_LO)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_sfx_pitch_done".into()),
));
out.push(Instruction::new(INC, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_sfx_pitch_done".into()),
));
}
out.push(Instruction::new(NOP, AM::Label("__audio_sfx_done".into())));
// ── Music tick ──
// Bit 1 of ZP_MUSIC_STATE is "music is active". If clear, skip.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_STATE)));
out.push(Instruction::new(AND, AM::Immediate(0x02)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_music_done".into()),
));
// Active. Decrement the note counter; if nonzero after, bail.
out.push(Instruction::new(DEC, AM::ZeroPage(ZP_MUSIC_COUNTER)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_music_done".into()),
));
// Counter just hit zero — time to advance. Fall through to the
// "advance to next note" block below. The runtime calls this
// block from two places: end-of-note and start_music (which sets
// counter=0 then jumps here to trigger the first note).
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_advance".into()),
));
// Read the next pitch byte. LDA sets Z based on the value so
// we can dispatch on it cheaply:
// pitch == 0 → rest (fall through to __rest)
// pitch == 0xFF → sentinel (BNE past rest, then CMP + BEQ)
// otherwise → pitched (fall through to __pitched)
out.push(Instruction::new(LDY, AM::Immediate(0)));
out.push(Instruction::new(LDA, AM::IndirectY(ZP_MUSIC_PTR_LO)));
// Zero? → rest branch (mute pulse 2, skip period lookup).
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_music_not_rest".into()),
));
out.push(Instruction::new(LDA, AM::Immediate(0x30)));
out.push(Instruction::new(STA, AM::Absolute(0x4004)));
out.push(Instruction::new(
JMP,
AM::Label("__audio_music_load_dur".into()),
));
// Not zero — check sentinel, otherwise it's a real note.
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_not_rest".into()),
));
out.push(Instruction::new(CMP, AM::Immediate(0xFF)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_music_eot".into()),
));
// Fall through to the pitched branch — A still holds pitch.
out.push(Instruction::new(
JMP,
AM::Label("__audio_music_pitched".into()),
));
// Pitched branch: A already holds pitch (1..=60). Index the
// period table and write $4006 (period lo) and $4007 (period
// hi + length counter). Each table entry is 2 bytes.
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_pitched".into()),
));
// Rewrite envelope byte ($4004) from music state so we don't
// depend on pulse-2 length counter. Extract duty (bits 6-7) and
// volume (bits 2-5) from state, shift into position, OR with $30
// (length-halt + constant volume), write $4004.
//
// Save pitch in X so we still have it for the period lookup.
out.push(Instruction::new(TAX, AM::Implied));
// Build envelope byte.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_STATE)));
out.push(Instruction::new(AND, AM::Immediate(0xC0))); // keep duty bits
out.push(Instruction::new(STA, AM::ZeroPage(0x04))); // scratch
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_STATE)));
out.push(Instruction::new(AND, AM::Immediate(0x3C))); // keep volume<<2
out.push(Instruction::new(LSR, AM::Accumulator));
out.push(Instruction::new(LSR, AM::Accumulator));
out.push(Instruction::new(ORA, AM::ZeroPage(0x04)));
out.push(Instruction::new(ORA, AM::Immediate(0x30)));
out.push(Instruction::new(STA, AM::Absolute(0x4004)));
// Period lookup via a ZP pointer. X holds pitch (1..=60).
//
// 1. Set (ZP_SCRATCH = __period_table).
// 2. A = (pitch - 1) * 2 — byte offset in the 2-byte-per-entry
// table.
// 3. Y = A.
// 4. LDA (ZP_SCRATCH),Y → period_lo → STA $4006.
// 5. INY; LDA (ZP_SCRATCH),Y → period_hi → STA $4007.
//
// `$02`/`$03` are the multiply/divide scratch slots but the NMI
// audio tick never calls mul/div, so they're free to reuse here.
// A proper `Absolute,Y` addressing mode with a symbolic label
// would save the pointer setup, but our asm layer doesn't have
// that yet and the extra 8 cycles per frame are negligible.
out.push(Instruction::new(LDA, AM::SymbolLo("__period_table".into())));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
out.push(Instruction::new(LDA, AM::SymbolHi("__period_table".into())));
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
out.push(Instruction::new(TXA, AM::Implied));
out.push(Instruction::new(SEC, AM::Implied));
out.push(Instruction::new(SBC, AM::Immediate(1)));
out.push(Instruction::new(ASL, AM::Accumulator));
out.push(Instruction::new(TAY, AM::Implied));
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
out.push(Instruction::new(STA, AM::Absolute(0x4006)));
out.push(Instruction::new(INY, AM::Implied));
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
// The period-table high byte already has the length-counter
// load bits baked in (see `gen_period_table`), so a raw store
// here retriggers the note. But retriggering every time the
// duration expires is fine — it's how trackers work.
out.push(Instruction::new(STA, AM::Absolute(0x4007)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_load_dur".into()),
));
// Advance pointer past the pitch byte we just consumed.
out.push(Instruction::new(INC, AM::ZeroPage(ZP_MUSIC_PTR_LO)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_music_dur_hi_ok".into()),
));
out.push(Instruction::new(INC, AM::ZeroPage(ZP_MUSIC_PTR_HI)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_dur_hi_ok".into()),
));
// Read duration through the advanced pointer and stash it in
// ZP_MUSIC_COUNTER.
out.push(Instruction::new(LDY, AM::Immediate(0)));
out.push(Instruction::new(LDA, AM::IndirectY(ZP_MUSIC_PTR_LO)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_COUNTER)));
// Advance past the duration byte.
out.push(Instruction::new(INC, AM::ZeroPage(ZP_MUSIC_PTR_LO)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_music_ptr2_ok".into()),
));
out.push(Instruction::new(INC, AM::ZeroPage(ZP_MUSIC_PTR_HI)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_ptr2_ok".into()),
));
out.push(Instruction::new(
JMP,
AM::Label("__audio_music_done".into()),
));
// ── End-of-track branch ──
out.push(Instruction::new(NOP, AM::Label("__audio_music_eot".into())));
// Check loop flag (bit 0 of ZP_MUSIC_STATE). If set, rewind ptr
// to base and re-enter the advance path. Otherwise stop.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_STATE)));
out.push(Instruction::new(AND, AM::Immediate(0x01)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_music_stop".into()),
));
// Looping: copy base pointer back into current pointer and
// re-enter the advance path.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_BASE_LO)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_PTR_LO)));
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_BASE_HI)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_PTR_HI)));
out.push(Instruction::new(
JMP,
AM::Label("__audio_music_advance".into()),
));
// Non-looping stop: mute pulse 2 and clear music state.
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_stop".into()),
));
out.push(Instruction::new(LDA, AM::Immediate(0x30)));
out.push(Instruction::new(STA, AM::Absolute(0x4004)));
out.push(Instruction::new(LDA, AM::Immediate(0)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_STATE)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_COUNTER)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_done".into()),
));
// ── Noise channel tick (optional, gated on `has_noise`) ──
//
// Structurally identical to the pulse-1 sfx tick above: walk a
// per-frame envelope blob via an indirect-indexed load and
// write each byte to the APU noise volume register at $400C.
// The pointer lives in main RAM at [AUDIO_NOISE_PTR_LO,
// AUDIO_NOISE_PTR_HI]; the tick stashes it in ZP scratch $02/$03
// for the duration of the block because the 6502 has no
// (abs),Y addressing.
if has_noise {
out.extend(gen_noise_tick());
}
// ── Triangle channel tick (optional, gated on `has_triangle`) ──
//
// Same shape as the noise tick but writes to $4008 (the linear
// counter) instead of a volume register. Triangle has no volume,
// so the envelope blob just encodes "keep the linear counter
// loaded" (nonzero hold) or "silence" (the $80 sentinel).
if has_triangle {
out.extend(gen_triangle_tick());
}
out.push(Instruction::implied(RTS));
out
}
/// Generate the noise-channel sfx tick. Appended to
/// [`gen_audio_tick`] only when the program declares at least one
/// noise sfx (`__noise_used` marker). Reads envelope bytes from the
/// main-RAM pointer at [`AUDIO_NOISE_PTR_LO`] / [`AUDIO_NOISE_PTR_HI`]
/// and writes them to the APU noise volume register at `$400C`.
/// A zero envelope byte is the mute sentinel — on it the tick
/// silences the channel and clears [`AUDIO_NOISE_COUNTER`].
fn gen_noise_tick() -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(
NOP,
AM::Label("__audio_noise_tick".into()),
));
// If counter is zero, no noise sfx is playing; skip the block.
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_NOISE_COUNTER)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_noise_done".into()),
));
// Load the main-RAM pointer into ZP scratch $02/$03 so we can
// do an indirect-indexed read (6502 has no (abs),Y mode).
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_NOISE_PTR_LO)));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_NOISE_PTR_HI)));
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
// Read envelope byte through the scratch pointer.
out.push(Instruction::new(LDY, AM::Immediate(0)));
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
// Zero sentinel? branch to the write path if nonzero.
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_noise_write".into()),
));
// Sentinel: mute pulse-noise ($4000-compatible encoding:
// length-halt + constant-volume + volume 0) and clear the
// counter so this block bails on subsequent NMIs.
out.push(Instruction::new(LDA, AM::Immediate(0x30)));
out.push(Instruction::new(STA, AM::Absolute(0x400C)));
out.push(Instruction::new(LDA, AM::Immediate(0)));
out.push(Instruction::new(STA, AM::Absolute(AUDIO_NOISE_COUNTER)));
out.push(Instruction::new(
JMP,
AM::Label("__audio_noise_done".into()),
));
// Write envelope byte and advance the 16-bit pointer by 1.
out.push(Instruction::new(
NOP,
AM::Label("__audio_noise_write".into()),
));
out.push(Instruction::new(STA, AM::Absolute(0x400C)));
out.push(Instruction::new(INC, AM::Absolute(AUDIO_NOISE_PTR_LO)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_noise_ptr_ok".into()),
));
out.push(Instruction::new(INC, AM::Absolute(AUDIO_NOISE_PTR_HI)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_noise_ptr_ok".into()),
));
out.push(Instruction::new(
NOP,
AM::Label("__audio_noise_done".into()),
));
out
}
/// Generate the triangle-channel sfx tick. Same shape as
/// [`gen_noise_tick`] but writes to `$4008` (the linear counter
/// reload) instead of a volume register. Triangle has no volume —
/// the envelope bytes are "keep holding" tokens that the runtime
/// keeps writing every frame so the linear counter never underruns
/// and the channel never auto-silences. A `0x80` byte is the mute
/// sentinel (linear control bit set, reload = 0 → silence next
/// frame).
fn gen_triangle_tick() -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(
NOP,
AM::Label("__audio_triangle_tick".into()),
));
// If counter is zero, no triangle sfx is playing.
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_TRIANGLE_COUNTER)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_triangle_done".into()),
));
// Stash pointer to ZP scratch for indirect-indexed load.
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_TRIANGLE_PTR_LO)));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_TRIANGLE_PTR_HI)));
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
out.push(Instruction::new(LDY, AM::Immediate(0)));
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
// Write the envelope byte to $4008. For triangle, `$80` is the
// mute sentinel (linear counter reload = 0 with control bit
// set). We detect it by CMP + BEQ so the counter can be cleared.
out.push(Instruction::new(STA, AM::Absolute(0x4008)));
out.push(Instruction::new(CMP, AM::Immediate(0x80)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_triangle_advance".into()),
));
// Sentinel: clear counter so we bail next frame. The $80 write
// above already mutes the triangle channel.
out.push(Instruction::new(LDA, AM::Immediate(0)));
out.push(Instruction::new(STA, AM::Absolute(AUDIO_TRIANGLE_COUNTER)));
out.push(Instruction::new(
JMP,
AM::Label("__audio_triangle_done".into()),
));
// Advance the pointer by 1 for the next frame.
out.push(Instruction::new(
NOP,
AM::Label("__audio_triangle_advance".into()),
));
out.push(Instruction::new(INC, AM::Absolute(AUDIO_TRIANGLE_PTR_LO)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_triangle_ptr_ok".into()),
));
out.push(Instruction::new(INC, AM::Absolute(AUDIO_TRIANGLE_PTR_HI)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_triangle_ptr_ok".into()),
));
out.push(Instruction::new(
NOP,
AM::Label("__audio_triangle_done".into()),
));
out
}
/// Generate the builtin period table that the music tick uses to
/// translate note indices into pulse-channel period values. The
/// table covers five octaves (C1B5) for 60 entries, 2 bytes each.
///
/// Entry 0 is `C1` (index 1 in user notes), entry 59 is `B5` (index
/// 60). Pitch 0 is the "rest" sentinel and is not present in the
/// table — the driver handles rests before indexing.
///
/// The high byte of each entry is `((period >> 8) & 0x07) | 0x08`.
/// Setting bit 3 pre-loads the length counter to index 1 (254 frames)
/// so any note held beyond the envelope will still play out naturally
/// when the track later falls into a rest — without this, pulse 2
/// would silence itself after ~4 frames on hardware.
#[must_use]
pub fn gen_period_table() -> Vec<Instruction> {
// NTSC CPU = 1.789773 MHz. Pulse channel frequency:
// f = CPU / (16 * (period + 1))
// Solving for period given a target frequency f:
// period = CPU / (16 * f) - 1
//
// We compute the 60 entries once at build time (here) using
// equal-tempered tuning anchored at A4 = 440 Hz.
const CPU: f64 = 1_789_773.0;
const A4_HZ: f64 = 440.0;
let mut out = Vec::new();
out.push(Instruction::new(NOP, AM::Label("__period_table".into())));
// Semitone offset from A4 for index `i` (0-based from C1).
// A4 is MIDI 69. C1 is MIDI 24. So semitones from A4 to C1 is
// -45 — our table starts at C1 so `offset(i) = i - 45`.
let mut bytes: Vec<u8> = Vec::with_capacity(120);
for i in 0i32..60 {
let semitone_offset = f64::from(i - 45);
let freq = A4_HZ * 2f64.powf(semitone_offset / 12.0);
let period_f = CPU / (16.0 * freq) - 1.0;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let period = period_f.round().clamp(0.0, 2047.0) as u16;
let lo = (period & 0xFF) as u8;
// High 3 bits of period + length counter load bits.
// 0x08 = length counter index 1 = 254 frames.
let hi = ((period >> 8) as u8 & 0x07) | 0x08;
bytes.push(lo);
bytes.push(hi);
}
out.push(Instruction::new(NOP, AM::Bytes(bytes)));
out
}
/// Generate a labelled data block emitting `bytes` verbatim into the
/// ROM at the address the assembler places this block. Used by the
/// linker to splice compiled sfx and music blobs into the code
/// section so that `LDA #<Name; STA ptr_lo` from the IR codegen can
/// resolve to the right in-ROM address.
#[must_use]
pub fn gen_data_block(label: &str, bytes: Vec<u8>) -> Vec<Instruction> {
vec![
Instruction::new(NOP, AM::Label(label.to_string())),
Instruction::new(NOP, AM::Bytes(bytes)),
]
}
/// Generate 8 / 8 -> 8 software divide routine (restoring division).
///
/// Input: A = dividend, zero-page $02 = divisor
/// Output: A = quotient, $03 = remainder
pub fn gen_divide() -> Vec<Instruction> {
let mut out = Vec::new();
// Label for the subroutine entry
out.push(Instruction::new(NOP, AM::Label("__divide".into())));
// Store dividend in $04
out.push(Instruction::new(STA, AM::ZeroPage(0x04)));
// Clear remainder
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_DIV_REMAINDER)));
// Loop counter: 8 bits
out.push(Instruction::new(LDX, AM::Immediate(0x08)));
// __div_loop:
out.push(Instruction::new(NOP, AM::Label("__div_loop".into())));
// Shift dividend left into remainder
out.push(Instruction::new(ASL, AM::ZeroPage(0x04)));
out.push(Instruction::new(ROL, AM::ZeroPage(ZP_DIV_REMAINDER)));
// Try to subtract divisor from remainder
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_DIV_REMAINDER)));
out.push(Instruction::implied(SEC));
out.push(Instruction::new(SBC, AM::ZeroPage(ZP_DIV_DIVISOR)));
// If remainder >= divisor (no borrow), keep subtraction
out.push(Instruction::new(
BCC,
AM::LabelRelative("__div_no_sub".into()),
));
// Store updated remainder
out.push(Instruction::new(STA, AM::ZeroPage(ZP_DIV_REMAINDER)));
// Set bit 0 of quotient (in $04, which we shifted left)
out.push(Instruction::new(INC, AM::ZeroPage(0x04)));
// __div_no_sub:
out.push(Instruction::new(NOP, AM::Label("__div_no_sub".into())));
// Decrement counter
out.push(Instruction::implied(DEX));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__div_loop".into()),
));
// Load quotient into A
out.push(Instruction::new(LDA, AM::ZeroPage(0x04)));
out.push(Instruction::implied(RTS));
out
}
// ─── Bank switching ────────────────────────────────────────────────
//
// NEScript supports bank switching for MMC1, UxROM, and MMC3. The
// linker lays out PRG ROM with a single fixed bank ($C000-$FFFF)
// holding the runtime, NMI, IRQ vectors, and any cross-bank
// trampolines, plus zero or more switchable 16 KB banks mapped at
// $8000-$BFFF. The helpers below emit:
//
// * `gen_mapper_init` — reset-time configuration that puts the
// last physical bank at $C000 and (depending on the mapper)
// sets a known mirroring mode so the compiler's
// `Mirroring::{Horizontal,Vertical}` selection matches.
// * `gen_bank_select` — a subroutine callable with the target bank
// number in A that selects the correct switchable bank at $8000.
// * `gen_bank_trampoline` — a per-(target, bank) stub placed in
// the fixed bank. Callers `JSR` into the trampoline, which
// records the current bank, switches to the target bank, calls
// the entry label in that bank, and switches back.
//
// The trampolines never physically return to the switchable bank —
// control is always handed back to the fixed bank after the callee
// returns. Users don't touch these routines directly; the linker
// emits them from the `bank` declarations in the program AST.
/// Zero-page slot used by the bank-select routine to stash the
/// requested bank number so `__bank_return` can restore it when a
/// trampoline finishes.
pub const ZP_BANK_CURRENT: u8 = 0x10;
/// Generate the reset-time mapper initialization sequence. Splices
/// after `gen_init` but before any user code runs. For NROM this is
/// a no-op — `gen_init` already sets up everything NROM needs.
///
/// `total_prg_banks` is the total number of 16 KB PRG banks in the
/// ROM; MMC1/MMC3 need this to fix the *last* physical bank at
/// $C000. `UxROM` is hardwired — its last bank is always fixed.
#[must_use]
pub fn gen_mapper_init(
mapper: Mapper,
mirroring: Mirroring,
total_prg_banks: usize,
) -> Vec<Instruction> {
let mut out = match mapper {
Mapper::NROM => Vec::new(),
Mapper::MMC1 => gen_mmc1_init(mirroring),
Mapper::UxROM => gen_uxrom_init(total_prg_banks),
Mapper::MMC3 => gen_mmc3_init(mirroring),
};
// Initialize ZP_BANK_CURRENT to the fixed bank index for any
// banked mapper. The trampoline emitted by
// `gen_bank_trampoline` reads this slot to decide which bank
// to restore after a cross-bank call, so it has to be a
// sensible value from the very first call. Without this the
// RAM-clear leaves it at $00, which would put bank 0 at
// $8000 instead of the fixed bank after a fixed-bank caller's
// first cross-bank call — a behavior change vs. the pre-
// banked-banked codegen that some examples rely on.
if mapper != Mapper::NROM && total_prg_banks > 0 {
#[allow(clippy::cast_possible_truncation)]
let fixed_bank_index = (total_prg_banks - 1) as u8;
out.push(Instruction::new(LDA, AM::Immediate(fixed_bank_index)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_BANK_CURRENT)));
}
out
}
/// MMC1 reset: pulse the reset bit, then write the control register.
/// Control-register layout (5 bits, serialized LSB-first into any
/// $8000-$FFFF address):
/// bit 4 — CHR bank mode (0 = 8 KB, 1 = two 4 KB banks)
/// bit 3 — PRG bank mode bit 1
/// bit 2 — PRG bank mode bit 0
/// 11 = 16 KB banks, fix last at $C000, switchable at $8000
/// bit 1-0 — mirroring
/// 00 = 1-screen lo, 01 = 1-screen hi,
/// 10 = vertical, 11 = horizontal
///
/// We pick mode `11` (fixed last bank) so the fixed bank appears at
/// $C000 exactly the same way as NROM, which lets us reuse the NROM
/// layout for all the runtime code that already exists.
fn gen_mmc1_init(mirroring: Mirroring) -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(NOP, AM::Label("__mmc1_init".into())));
// Reset pulse: any $8000-range write with bit 7 set flushes the
// 5-bit shift register and resets the internal config to
// mode 3 (fixed last, 16 KB banks).
out.push(Instruction::new(LDA, AM::Immediate(0x80)));
out.push(Instruction::new(STA, AM::Absolute(0x8000)));
// Control register value.
let mirror_bits = match mirroring {
Mirroring::Horizontal => 0b11,
Mirroring::Vertical => 0b10,
};
// 16 KB PRG, fix last at $C000 (bits 2-3 = 11), 8 KB CHR
// (bit 4 = 0), plus mirroring bits.
let control: u8 = 0b0_11_00 | mirror_bits;
// Serialize the 5 bits into the shift register. Each write
// uses STA $8000 (which maps to the control register because
// the address falls in the $8000-$9FFF range).
out.extend(gen_mmc1_serial_write(control, 0x8000));
out
}
/// Emit 5 serialized writes of `value` to `addr`, shifting right
/// between writes. Used by MMC1 bank-switch code (registers all live
/// in $8000-$FFFF and are selected by the top two address bits).
fn gen_mmc1_serial_write(value: u8, addr: u16) -> Vec<Instruction> {
let mut out = Vec::new();
// Bit 0 goes out first, then bit 1, etc. We use immediate loads
// for each bit so the sequence has no hidden dependencies on
// the current A register.
for i in 0..5 {
let bit = (value >> i) & 1;
out.push(Instruction::new(LDA, AM::Immediate(bit)));
out.push(Instruction::new(STA, AM::Absolute(addr)));
}
out
}
/// `UxROM` reset: the last 16 KB PRG bank is always fixed at $C000,
/// and the switchable bank at $8000 defaults to bank 0 on power-on.
/// Some `UxROM` boards have bus conflicts — any write must match the
/// byte in ROM — so we use a small bank-select table at a known
/// address (`__bank_select_table`) generated into the fixed bank.
fn gen_uxrom_init(_total_banks: usize) -> Vec<Instruction> {
// No explicit init required: UxROM powers up with bank 0 at
// $8000 and the last bank fixed at $C000, which is exactly
// what we want. We still emit the label so debuggers can find
// the (empty) init span.
vec![Instruction::new(NOP, AM::Label("__uxrom_init".into()))]
}
/// MMC3 reset: choose PRG mode 0 (last two banks fixed at
/// $C000-$FFFF) and initialise bank 0 at $8000, bank 1 at $A000.
/// Mirroring is programmed via $A000 (only meaningful when CHR
/// uses the internal mode — for our CHR ROM layout it's still
/// the safest place to latch).
fn gen_mmc3_init(mirroring: Mirroring) -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(NOP, AM::Label("__mmc3_init".into())));
// Select PRG-bank-0 register (6) with PRG mode bit 6 = 0
// (meaning $8000 is switchable, $C000/$E000 are fixed at the
// last two banks).
out.push(Instruction::new(LDA, AM::Immediate(0x06)));
out.push(Instruction::new(STA, AM::Absolute(0x8000)));
out.push(Instruction::new(LDA, AM::Immediate(0x00))); // bank 0 at $8000
out.push(Instruction::new(STA, AM::Absolute(0x8001)));
// Select PRG-bank-1 register (7) and load bank 1 at $A000.
out.push(Instruction::new(LDA, AM::Immediate(0x07)));
out.push(Instruction::new(STA, AM::Absolute(0x8000)));
out.push(Instruction::new(LDA, AM::Immediate(0x01)));
out.push(Instruction::new(STA, AM::Absolute(0x8001)));
// Mirroring: $A000, bit 0 — 0 = vertical, 1 = horizontal.
let mirror = match mirroring {
Mirroring::Horizontal => 0x01,
Mirroring::Vertical => 0x00,
};
out.push(Instruction::new(LDA, AM::Immediate(mirror)));
out.push(Instruction::new(STA, AM::Absolute(0xA000)));
// Leave IRQs disabled until the user code enables them.
out.push(Instruction::new(STA, AM::Absolute(0xE000)));
out
}
/// Generate the `__bank_select` subroutine. Input: A = desired bank
/// number (0-based, physical PRG bank index). Output: that bank is
/// mapped to $8000-$BFFF. Clobbers A (and the internal shift
/// registers where applicable). The routine ends in RTS so callers
/// can `JSR __bank_select` anywhere it's callable from.
///
/// The bank number is stashed in `ZP_BANK_CURRENT` so `__bank_select`
/// and its trampolines can restore it after a callee returns.
#[must_use]
pub fn gen_bank_select(mapper: Mapper) -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(NOP, AM::Label("__bank_select".into())));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_BANK_CURRENT)));
match mapper {
Mapper::NROM => {
// NROM has no switchable banks, so the routine is a
// no-op. We still emit it so user code can unconditionally
// call `__bank_select` regardless of mapper.
out.push(Instruction::implied(RTS));
}
Mapper::MMC1 => {
// Write 5 bits of A (LSB first) into the shift register
// at $E000 (PRG-bank select). Between writes we LSR A
// to shift the next bit into position 0.
for i in 0..5 {
if i > 0 {
out.push(Instruction::new(LSR, AM::Accumulator));
}
out.push(Instruction::new(STA, AM::Absolute(0xE000)));
}
out.push(Instruction::implied(RTS));
}
Mapper::UxROM => {
// UxROM: write the bank number to any address in
// $8000-$FFFF. On boards with bus conflicts the CPU's
// write and the ROM byte at that address are ANDed on
// the data bus, so we must write to an address whose
// ROM byte already equals the bank number. The linker
// splices a 256-byte table (`__bank_select_table`,
// bytes 0..255) into the fixed bank, and we index into
// it with X = bank number: `STA __bank_select_table, X`
// stores A (= bank number) at
// `__bank_select_table + X`, whose ROM byte is exactly
// X, so bus = A = X = ROM — no conflict.
//
// Previously this wrote to a fixed `$FFF0`, which
// happens to work on emulators that don't simulate bus
// conflicts (jsnes, Mesen permissive) but would glitch
// on real hardware because a single ROM byte can't
// match every possible bank number.
out.push(Instruction::implied(TAX));
out.push(Instruction::new(
STA,
AM::LabelAbsoluteX("__bank_select_table".into()),
));
out.push(Instruction::implied(RTS));
}
Mapper::MMC3 => {
// MMC3: `$8000 = 6` selects PRG-bank-0 register, then
// write bank to `$8001`. We save/restore X because
// some callers use X as a loop counter across the
// switch.
out.push(Instruction::implied(PHA));
out.push(Instruction::new(LDA, AM::Immediate(0x06)));
out.push(Instruction::new(STA, AM::Absolute(0x8000)));
out.push(Instruction::implied(PLA));
out.push(Instruction::new(STA, AM::Absolute(0x8001)));
out.push(Instruction::implied(RTS));
}
}
out
}
/// Generate a cross-bank trampoline stub. Placed in the fixed bank
/// and called by *any* user code via `JSR <tramp_label>` regardless
/// of which bank the caller currently lives in. Behavior:
///
/// 1. Read [`ZP_BANK_CURRENT`] into A, push it on the hardware
/// stack — that's the bank we'll need to switch back to.
/// 2. Load the target bank number into A, JSR `__bank_select`.
/// 3. JSR the user-supplied entry label inside the target bank.
/// 4. Pull the saved bank back into A and JSR `__bank_select` to
/// restore the caller's view of $8000-$BFFF.
/// 5. RTS.
///
/// The save/restore via `ZP_BANK_CURRENT + PHA/PLA` makes the same
/// trampoline work for **fixed-bank → switchable-bank** *and*
/// **switchable-bank → switchable-bank** call directions: the
/// caller's bank ends up restored regardless of where the call
/// originated. Nested cross-bank calls compose because each
/// trampoline's PHA/PLA pair is balanced against its own JSR/RTS,
/// so the saved bank values stack like any other 6502 frame.
///
/// The trampoline body itself lives in the fixed bank, which is
/// always mapped at `$C000-$FFFF`, so it's reachable from every
/// switchable bank without further mapper trickery.
///
/// `tramp_label` is the label that callers will JSR (the IR codegen
/// emits `JSR __tramp_<fn_name>` at every cross-bank call site).
/// `entry_label` is the label inside the target bank that holds the
/// callee's first instruction — conventionally `__ir_fn_<fn_name>`,
/// the same label IR codegen would have emitted for an in-bank call.
/// `bank_index` is the physical PRG bank number of the target bank.
#[must_use]
pub fn gen_bank_trampoline(
tramp_label: &str,
entry_label: &str,
bank_index: u8,
) -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(NOP, AM::Label(tramp_label.to_string())));
// Save the caller's current bank. `__bank_select` writes its
// input into ZP_BANK_CURRENT, so this slot already mirrors the
// last-selected bank (initialized to the fixed bank index by
// `gen_mapper_init` so even fixed-bank callers see a sane
// value the first time around).
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_BANK_CURRENT)));
out.push(Instruction::implied(PHA));
// Switch to target bank.
out.push(Instruction::new(LDA, AM::Immediate(bank_index)));
out.push(Instruction::new(JSR, AM::Label("__bank_select".into())));
// Call the user's entry point in that bank. The label lives in
// the switchable bank and is resolved by the linker after the
// banked code is assembled.
out.push(Instruction::new(JSR, AM::Label(entry_label.to_string())));
// Restore the caller's bank (pulled from the stack) so control
// returns with $8000-$BFFF showing whatever the caller had
// mapped before the trampoline ran.
out.push(Instruction::implied(PLA));
out.push(Instruction::new(JSR, AM::Label("__bank_select".into())));
out.push(Instruction::implied(RTS));
out
}
/// Generate the bus-conflict avoidance table used by `UxROM`. The table
/// lives at a known offset in the fixed bank and contains 256 bytes
/// of increasing values (0, 1, 2, ...). Writing bank `n` to
/// `__bank_select_table + n` guarantees the bus value matches the
/// ROM byte at that address, avoiding conflict-driven glitches on
/// real `UxROM` hardware.
#[must_use]
pub fn gen_uxrom_bank_table() -> Vec<Instruction> {
let bytes: Vec<u8> = (0..=255u16).map(|i| i as u8).collect();
vec![
Instruction::new(NOP, AM::Label("__bank_select_table".into())),
Instruction::new(NOP, AM::Bytes(bytes)),
]
}