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

platformer: end-to-end side-scroller demo + three runtime bug fixes

Adds `examples/platformer.ne`, a full side-scrolling game that
exercises nearly every subsystem of the compiler in one program:
custom CHR tileset, 32×30 background nametable with per-region
attribute palettes, 2×2 metasprite hero with gravity/jump physics,
wrap-around horizontal scrolling, moving enemies, coin pickups,
user-declared SFX + music, and a Title → Playing state machine
with autopilot so the headless jsnes harness captures real
gameplay at frame 180. Tile art + nametable are generated by
`scripts/gen_platformer_tiles.rs` (`cargo run --bin gen_platformer_tiles`).

Building this out uncovered three independent runtime bugs that
together made the example render as black-on-black smileys. All
three are fixed in this commit:

1. **`gen_init` enabled sprite rendering before the linker's
   initial palette/background load runs.** The PPU's v-register
   auto-increments on every `$2007` write *during active
   rendering*, so the palette load (32 B) and nametable load
   (1024 B) were scrambled past the first ~72 bytes — every
   existing program with a `background Level { ... }` block was
   silently rendering zero-filled VRAM. Fix: leave `PPU_MASK = 0`
   at the end of `gen_init` and emit a new `gen_enable_rendering`
   call *after* all initial VRAM writes complete.

2. **Audio tick corrupted `ZP_CURRENT_STATE`.** The audio
   driver's period-table lookup reused `$02/$03` as a temporary
   indirect pointer with a comment claiming the slots were free
   because the tick doesn't call mul/div. But `$03` is also
   `ZP_CURRENT_STATE` used by the state dispatch loop, so every
   music note silently overwrote the state index with the high
   byte of `__period_table` (`0xC5` in the platformer ROM),
   wedging the state machine forever. Fix: `gen_nmi` now PHAs
   `$02/$03` on entry and PLA-restores them on exit, and the
   audio tick JSR moves inside that save/restore window (it used
   to be spliced by the linker *before* the register saves, so
   even A/X/Y were technically being trashed pre-save). Only
   `audio_demo`'s audio hash shifts (its note timings move a few
   cycles); every other golden is unchanged.

3. **Sub-palette mirroring footgun.** Writing a 32-byte palette
   blob sequentially causes the sprite sub-palettes' "index 0"
   slots at `$3F10/$3F14/$3F18/$3F1C` to clobber the background
   universal colour at `$3F00/$3F04/$3F08/$3F0C` via NES hardware
   mirroring. The example's palette sets all eight first bytes
   to `$22` (sky blue) for this reason; `docs/future-work.md`
   picks up a TODO to warn on inconsistent first-byte values in
   the analyzer.

Also:

- `docs/platformer.gif` — 6-second recording of the example
  running in jsnes, generated by the new
  `tests/emulator/record_gif.mjs` puppeteer helper (encodes via
  `gifenc`, committed as a dev-dependency under
  `tests/emulator/package.json`).
- README / examples/README tables and the 497-test count are
  updated to cover the new example.

https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
This commit is contained in:
Claude 2026-04-13 13:04:26 +00:00
parent 958f41d340
commit 688d9afcec
No known key found for this signature in database
16 changed files with 1171 additions and 25 deletions

View file

@ -160,15 +160,39 @@ pub fn gen_init() -> Vec<Instruction> {
AM::LabelRelative("__vblankwait2".into()),
));
// Enable PPU (sprites from pattern table 0, enable NMI)
// 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.push(Instruction::new(LDA, AM::Immediate(0x10))); // show sprites
out.push(Instruction::new(STA, AM::Absolute(PPU_MASK)));
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.
///
@ -176,8 +200,17 @@ pub fn gen_init() -> Vec<Instruction> {
/// 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).
#[must_use]
pub fn gen_nmi(has_ppu_updates: bool) -> Vec<Instruction> {
pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool) -> Vec<Instruction> {
let mut out = Vec::new();
// Save registers
@ -187,6 +220,24 @@ pub fn gen_nmi(has_ppu_updates: bool) -> Vec<Instruction> {
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));
// 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
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(STA, AM::Absolute(OAM_ADDR)));
@ -228,6 +279,13 @@ pub fn gen_nmi(has_ppu_updates: bool) -> Vec<Instruction> {
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));

View file

@ -70,7 +70,7 @@ fn init_assembles_without_error() {
#[test]
fn nmi_saves_and_restores_registers() {
let nmi = gen_nmi(false);
let nmi = gen_nmi(false, false);
// First three instructions should push A, X, Y
assert_eq!(nmi[0].opcode, PHA);
assert_eq!(nmi[1].opcode, TXA);
@ -86,7 +86,7 @@ fn nmi_saves_and_restores_registers() {
#[test]
fn nmi_triggers_oam_dma() {
let nmi = gen_nmi(false);
let nmi = gen_nmi(false, false);
let has_dma = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4014));
@ -95,7 +95,7 @@ fn nmi_triggers_oam_dma() {
#[test]
fn nmi_reads_controller() {
let nmi = gen_nmi(false);
let nmi = gen_nmi(false, false);
// Should write strobe to $4016
let has_strobe = nmi
.iter()
@ -105,7 +105,7 @@ fn nmi_reads_controller() {
#[test]
fn nmi_sets_frame_flag() {
let nmi = gen_nmi(false);
let nmi = gen_nmi(false, false);
let has_flag = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_FRAME_FLAG));
@ -114,7 +114,7 @@ fn nmi_sets_frame_flag() {
#[test]
fn nmi_assembles_without_error() {
let nmi = gen_nmi(false);
let nmi = gen_nmi(false, false);
let result = asm::assemble(&nmi, 0xF000);
assert!(!result.bytes.is_empty());
assert!(