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:
parent
958f41d340
commit
688d9afcec
16 changed files with 1171 additions and 25 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue