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

Merge pull request #17 from imjasonh/claude/test-platformer-game-6S3TX

This commit is contained in:
Jason Hall 2026-04-13 17:26:06 -04:00 committed by GitHub
commit 68184b7296
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 358 additions and 55 deletions

View file

@ -146,6 +146,21 @@ jobs:
- name: Diff each ROM's framebuffer against its golden
working-directory: tests/emulator
run: node run_examples.mjs
- name: Verify docs/platformer.gif is up to date
# The README embeds docs/platformer.gif as the project demo.
# gifenc + jsnes are deterministic, so rebuilding the gif from
# the current compiler should byte-match the committed copy.
# If it doesn't, the contributor changed the compiler / source
# / harness in a way that affects the observable gameplay of
# platformer.ne but forgot to regenerate the gif — the fix is
# the exact command printed in the error.
working-directory: tests/emulator
run: |
node record_gif.mjs platformer 360 2 /tmp/platformer.gif
if ! cmp -s ../../docs/platformer.gif /tmp/platformer.gif; then
echo "::error file=docs/platformer.gif::committed docs/platformer.gif is stale; rerun \`node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif\` and commit the new gif"
exit 1
fi
- name: Upload actual + diff PNGs on failure
if: failure()
uses: actions/upload-artifact@v4

View file

@ -22,6 +22,22 @@ re-derive the project conventions from scratch.
tmp path and fails if the committed version differs, pointing at
the exact `cargo run -- build examples/<name>.ne` to run. The
pre-commit hook under `scripts/pre-commit` catches this locally.
- **`docs/platformer.gif` is committed** and embedded in the
top-level README as the project demo. `gifenc` + `jsnes` are
deterministic, so the gif's bytes are a function of the compiler,
the runtime, the harness, and `examples/platformer.ne`. Any change
to those that affects the first ~6 seconds of observable platformer
gameplay must be followed by regenerating the gif:
```bash
node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif
```
and committing it in the same change. CI's `emulator` job renders
a fresh gif and fails if the committed one doesn't byte-match. The
pre-commit hook rebuilds the gif when `platformer.ne`, `platformer.nes`,
`record_gif.mjs`, or `harness.html` is staged (and `tests/emulator/node_modules`
is installed).
- `docs/future-work.md` lists the remaining gaps. If you implement
something from that file, update the doc in the same PR.

View file

@ -86,7 +86,7 @@ start Main
| [`structs_enums_for.ne`](examples/structs_enums_for.ne) | Structs, enums, `for` loops, struct literals |
| [`inline_asm_demo.ne`](examples/inline_asm_demo.ne) | Inline asm with `{var}` substitution, `poke`/`peek` |
| [`audio_demo.ne`](examples/audio_demo.ne) | Audio subsystem: user `sfx`/`music` blocks, builtin effects, `play`/`start_music`/`stop_music` |
| [`platformer.ne`](examples/platformer.ne) | **End-to-end side-scroller** — custom CHR tileset, full background nametable, metasprite player with gravity/jump physics, wrap-around scrolling, enemies, coins, user-declared SFX + music, and a Title → Playing state machine with auto-play for the headless harness |
| [`platformer.ne`](examples/platformer.ne) | **End-to-end side-scroller** — custom CHR tileset, full background nametable, metasprite player with gravity/jump physics, wrap-around scrolling, stomp-or-die enemy collisions, live stomp-count HUD, pickup coins, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness demonstrates the full gameplay loop (stomp, stomp, die, retry) inside six seconds |
## Compiler Commands

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 446 KiB

Before After
Before After

View file

@ -28,7 +28,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
| `mmc1_banked.ne` | MMC1, banks, multiply | Banked mapper with software multiply |
| `palette_and_background.ne` | palette, background, set_palette, load_background | Reset-time initial load plus vblank-safe runtime swaps |
| `friendly_assets.ne` | named colours, grouped palette, pixel art, tilemap+legend, palette_map, scalar sfx pitch, note-name music | Exercises every "friendlier" asset syntax at once — the `palette` uses `bg0..sp3` + a shared `universal:`, the sprite is authored as ASCII pixel art, the background uses a `legend { ... } + map:` tilemap with a `palette_map:` for attributes, the sfx uses a scalar `pitch:` + `envelope:` alias, and the music uses note names (`C4, E4 40, rest 10`) with a `tempo:` default. |
| `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, enemies, coin pickups, user-declared SFX + music, and a Title → Playing state machine with an autopilot so the headless harness still hits interesting gameplay at frame 180. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. |
| `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, stomp-or-die enemy collisions with a live stomp-count HUD, coin pickups, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness cycles through stomp, stomp, die, and retry inside six seconds. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. |
## Emulator Controls

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -3,12 +3,13 @@
// one program: custom CHR tiles, a full 32×30 background nametable
// with per-region attribute palettes, a multi-tile metasprite
// player with gravity/jump physics, wrap-around horizontal
// scrolling, moving enemies, collectible coins, a user-declared
// SFX envelope, a user-declared music track, and a title →
// playing → game-over state machine driven by both controller
// input and an autopilot so the jsnes golden harness (which runs
// headless with no simulated buttons) still captures an
// interesting frame-180 composition.
// scrolling, moving enemies with stomp-or-die contact logic, a
// coin HUD that tracks live score, collectible coins, user-declared
// SFX envelopes, a user-declared music track, and a
// title → playing → game-over state machine driven by both
// controller input and an autopilot so the jsnes golden harness
// (which runs headless with no simulated buttons) still captures
// the full gameplay loop inside the first ~6 seconds of demo.
//
// Controls (when played by a human):
// D-pad left/right — walk
@ -358,6 +359,12 @@ const ENEMY2_WORLD_X: u8 = 200
const COIN1_WORLD_X: u8 = 50
const COIN2_WORLD_X: u8 = 150
// How many auto-jumps the autopilot will pre-queue per life. Two
// is exactly enough to stomp enemy 1 and enemy 2 once each; after
// that the autopilot stops assisting so the headless harness gets
// to see the player walk into the next enemy, die, and retry.
const AUTOPILOT_JUMPS: u8 = 2
// ── Game state ──────────────────────────────────────────────
// Player physics
@ -369,9 +376,13 @@ var fall_vy: u8 = 0 // gravity accumulator
// World/camera
var camera_x: u8 = 0
var frame_tick: u8 = 0 // free-running frame counter
var jump_timer: u8 = 0 // autopilot: jump every N frames
var anim_tick: u8 = 0 // visual animation phase
// Gameplay
var alive: u8 = 1 // 0 = dying/dead, 1 = playable
var stomp_count: u8 = 0 // successful enemy stomps this life
var auto_jumps: u8 = 0 // proximity pre-jumps used this life
// ── Helper functions ────────────────────────────────────────
// Try to start a jump. Only valid when the player is on the
@ -423,6 +434,54 @@ fun draw_player() {
draw Tileset at: (PLAYER_SCREEN_X + 8, player_y + 8) frame: TILE_PLAYER_BR
}
// Resolve contact with an enemy whose screen X is `e_sx`. Mutates
// the player's physics / alive flag directly and returns 1 on a
// successful stomp, 0 on no contact or a fatal hit. The caller
// checks `alive` after invocation to decide whether to keep
// running the frame.
//
// The player's 16×16 hitbox is at ([80, 96), [player_y, player_y+16)).
// Each enemy's 8×8 hitbox is at ([e_sx, e_sx+8), [168, 176)). Those
// overlap horizontally when e_sx ∈ (72, 96) and overlap vertically
// when player_y ∈ (152, 176).
//
// Outcomes:
// - No overlap → no-op
// - Overlap while rising (rise_count > 0) → graceful
// pass-through. rise_count > 0 only happens right after a
// successful stomp bounce (try_jump() clears rise_count by
// the end of the same frame) or on the stomp's own upward
// leg, which we don't want to retrigger.
// - Overlap while falling, feet near enemy head → STOMP: bounce
// up, increment `stomp_count`, play Boing
// - Any other overlap (walking, on ground) → DEATH: set
// `alive = 0` and play the builtin `hit` sfx
fun resolve_enemy_hit(e_sx: u8) -> u8 {
if e_sx > 72 {
if e_sx < 96 {
if player_y > 152 {
if player_y < 176 {
// Real contact. Stomp if falling; otherwise, if
// we're not in the post-bounce grace window,
// die.
if fall_vy > 0 {
rise_count = 6
fall_vy = 0
stomp_count += 1
play Boing
return 1
}
if rise_count == 0 {
alive = 0
play hit
}
}
}
}
}
return 0
}
// ── States ──────────────────────────────────────────────────
state Title {
@ -468,8 +527,11 @@ state Playing {
fall_vy = 0
camera_x = 0
frame_tick = 0
jump_timer = 0
anim_tick = 0
alive = 1
stomp_count = 0
auto_jumps = 0
start_music Theme
}
on frame {
@ -490,31 +552,40 @@ state Playing {
camera_x -= 1
}
// ── Autopilot ────────────────────────────────────────
// The headless golden harness never presses buttons, so
// we advance the camera and periodically hop all by
// ourselves. A human holding right still accelerates
// forward — the two effects compose.
// Always advance camera one px per frame (baseline scroll).
camera_x += 1
jump_timer += 1
if jump_timer >= 40 {
jump_timer = 0
try_jump()
}
// ── Physics ──────────────────────────────────────────
step_physics()
// ── Collisions ───────────────────────────────────────
// Coin pickup when the player's screen X is within ±8 of
// a coin's screen position and the coin's Y range overlaps
// the player. Uses u8 subtraction so "screen X" of a
// world entity is just (world_x - camera_x).
// ── Compute screen positions of world entities ──────
var e1_sx: u8 = ENEMY1_WORLD_X - camera_x
var e2_sx: u8 = ENEMY2_WORLD_X - camera_x
var c1_sx: u8 = COIN1_WORLD_X - camera_x
var c2_sx: u8 = COIN2_WORLD_X - camera_x
// ── Proximity autopilot ─────────────────────────────
// Pre-jump when an enemy is 19 px ahead. The fall phase of
// a JUMP_RISE=12, GRAVITY_CAP=4 jump lands the player's
// feet at enemy head height ~20 frames later, by which
// point the autopilot camera has scrolled the enemy under
// the player. Limited to AUTOPILOT_JUMPS hops per life so
// the third enemy encounter becomes a scripted death,
// giving the golden harness a visible game-over sequence.
if auto_jumps < AUTOPILOT_JUMPS {
if on_ground == 1 {
if e1_sx == 99 {
try_jump()
auto_jumps += 1
}
if e2_sx == 99 {
try_jump()
auto_jumps += 1
}
}
}
// ── Physics ──────────────────────────────────────────
step_physics()
// ── Coin pickups ─────────────────────────────────────
// Coin collect: player occupies [80..96). A coin is "hit"
// when its screen X is in [72..96) (8-px tolerance on each
// side of the player's left edge).
@ -529,24 +600,17 @@ state Playing {
}
}
// Enemy stomp: if we're above an enemy horizontally *and*
// we're mid-fall (fall_vy > 0), count the stomp and
// bounce. Otherwise just ignore — the enemy slides past.
if e1_sx >= 72 and e1_sx < 96 {
if fall_vy > 0 {
if player_y + 16 >= ENEMY_Y {
play hit
rise_count = 6
fall_vy = 0
}
}
}
// ── Enemy collisions ────────────────────────────────
// Both enemies get the full stomp-or-die check; the helper
// mutates player state directly. If `alive` flips to 0
// we fall through the rest of the frame (drawing is
// guarded below) and transition to GameOver at the end.
resolve_enemy_hit(e1_sx)
resolve_enemy_hit(e2_sx)
// ── Drawing ──────────────────────────────────────────
draw_player()
// Enemies — walking on the grass line. anim_tick drives
// a small vertical bob so they visibly move on the golden.
// Enemies and coins are always drawn so the scene stays
// coherent even on the fatal-contact frame.
var ey: u8 = ENEMY_Y
if (anim_tick & 16) != 0 {
ey = ENEMY_Y - 2
@ -554,9 +618,6 @@ state Playing {
draw Tileset at: (e1_sx, ey) frame: TILE_ENEMY
draw Tileset at: (e2_sx, ENEMY_Y) frame: TILE_ENEMY
// Coins — alternate two vertical positions to fake a
// spin via position change (OAM attr flip isn't wired up
// in the current runtime).
var cy: u8 = COIN_Y
if (anim_tick & 8) != 0 {
cy = COIN_Y - 1
@ -564,11 +625,85 @@ state Playing {
draw Tileset at: (c1_sx, cy) frame: TILE_COIN
draw Tileset at: (c2_sx, COIN_Y) frame: TILE_COIN
// Live HUD: draw one coin per stomp in the top-left. The
// background palette-1 region covers this strip so the
// sprite coins read correctly against the brick band.
if stomp_count >= 1 {
draw Tileset at: (16, 24) frame: TILE_COIN
}
if stomp_count >= 2 {
draw Tileset at: (28, 24) frame: TILE_COIN
}
if stomp_count >= 3 {
draw Tileset at: (40, 24) frame: TILE_COIN
}
if stomp_count >= 4 {
draw Tileset at: (52, 24) frame: TILE_COIN
}
// Player is only drawn while alive — on the dying frame
// we show the scene without the hero on top of the enemy
// that killed them, which reads as "player got got".
if alive == 1 {
draw_player()
}
// ── PPU scroll latch ─────────────────────────────────
// Write the scroll at the very end of the frame so it's
// the last $2005 pair before the implicit wait_frame /
// NMI handshake, minimizing mid-frame artifacts.
scroll(camera_x, 0)
// Fatal contact this frame → kick the state machine over
// to GameOver on the next frame.
if alive == 0 {
transition GameOver
}
}
}
state GameOver {
var linger: u8 = 0
on enter {
linger = 0
stop_music
}
on frame {
linger += 1
// "GAME OVER" marker: a row of enemy tiles across the
// middle of the screen, plus a live readout of how many
// stomps the player racked up before dying (drawn as
// coins right underneath). The death-screen backdrop is
// whatever Playing last scrolled to — we don't clear it.
draw Tileset at: (96, 112) frame: TILE_ENEMY
draw Tileset at: (112, 112) frame: TILE_ENEMY
draw Tileset at: (128, 112) frame: TILE_ENEMY
draw Tileset at: (144, 112) frame: TILE_ENEMY
if stomp_count >= 1 {
draw Tileset at: (96, 128) frame: TILE_COIN
}
if stomp_count >= 2 {
draw Tileset at: (112, 128) frame: TILE_COIN
}
if stomp_count >= 3 {
draw Tileset at: (128, 128) frame: TILE_COIN
}
if stomp_count >= 4 {
draw Tileset at: (144, 128) frame: TILE_COIN
}
// Auto-retry after ~1 s so the headless harness sees the
// full loop. A human can hit Start to retry immediately.
if linger >= 60 {
transition Playing
}
if button.start {
transition Playing
}
}
}

Binary file not shown.

Binary file not shown.

View file

@ -39,4 +39,26 @@ if [ $stale -ne 0 ]; then
exit 1
fi
# Only rebuild docs/platformer.gif when platformer.nes or the gif
# recorder itself changed — the gif regeneration takes ~20s due to
# puppeteer's cold start, so we don't want to pay it on every commit.
# Skipped entirely if node_modules isn't installed in the emulator
# harness; the CI `emulator` job is the authoritative check.
changed_files=$(git diff --cached --name-only)
if echo "$changed_files" | grep -qE '^(examples/platformer\.(ne|nes)|tests/emulator/record_gif\.mjs|tests/emulator/harness\.html)$'; then
if [ -d tests/emulator/node_modules ]; then
echo " Rebuilding docs/platformer.gif (platformer.nes or recorder changed)..."
(cd tests/emulator && node record_gif.mjs platformer 360 2 /tmp/platformer-precheck.gif >/dev/null)
if ! cmp -s docs/platformer.gif /tmp/platformer-precheck.gif; then
echo " STALE: docs/platformer.gif"
echo " rerun: node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif"
rm -f /tmp/platformer-precheck.gif
exit 1
fi
rm -f /tmp/platformer-precheck.gif
else
echo " (skipping gif freshness check — tests/emulator/node_modules not installed)"
fi
fi
echo "All pre-commit checks passed."

View file

@ -218,13 +218,22 @@ fn strength_reduce_block(block: &mut IrBasicBlock) {
/// destination temps are no longer referenced anywhere in the block.
fn const_fold(program: &mut IrProgram) {
for func in &mut program.functions {
// Pre-compute function-wide source-operand usage: a LoadImm's
// destination may be read by an op in a sibling block or
// consumed by a branch/jump terminator in another block, so
// the per-block DCE below can't decide liveness by looking
// at its own block alone. Cf. the `and` / `or` short-circuit
// lowering: the false path writes `LoadImm(result, 0)` but
// `result` is read by the merge block's branch, not in the
// false block itself.
let func_used = collect_used_temps(func);
for block in &mut func.blocks {
const_fold_block(block);
const_fold_block(block, &func_used);
}
}
}
fn const_fold_block(block: &mut IrBasicBlock) {
fn const_fold_block(block: &mut IrBasicBlock, func_used: &HashSet<IrTemp>) {
let mut constants: HashMap<IrTemp, u8> = HashMap::new();
// First pass: fold arithmetic / comparison ops into LoadImm where possible.
@ -349,12 +358,21 @@ fn const_fold_block(block: &mut IrBasicBlock) {
}
}
// Second pass: remove LoadImm ops whose dest temps are no longer referenced
// as source operands by anything else in the block (ops + terminator).
let used = collect_used_temps_in_block(block);
// Second pass: remove LoadImm ops whose dest temps are no longer
// referenced locally AND aren't referenced function-wide. The
// function-wide check is what makes this pass correct in the
// presence of control-flow merges — a LoadImm written in one
// block and consumed in another (for example the `and`/`or`
// short-circuit false path, whose `LoadImm(result, 0)` is only
// read by the downstream merge block's branch terminator) must
// not be dropped. The previous implementation only consulted
// block-local usage and silently dropped these cross-block
// LoadImms, leaving the zero-page result slot to carry whatever
// value the *previous* AND/OR had written into it.
let used_local = collect_used_temps_in_block(block);
block.ops.retain(|op| {
if let IrOp::LoadImm(t, _) = op {
used.contains(t)
used_local.contains(t) || func_used.contains(t)
} else {
true
}
@ -397,7 +415,10 @@ fn collect_used_temps(func: &IrFunction) -> HashSet<IrTemp> {
used
}
/// Collect temps used as source operands within a single block (ops + terminator).
/// Collect temps used as source operands within a single block
/// (ops + terminator). Used by the per-block `LoadImm` DCE so we
/// can cheaply find local uses before falling back on the
/// function-wide liveness set.
fn collect_used_temps_in_block(block: &IrBasicBlock) -> HashSet<IrTemp> {
let mut used = HashSet::new();
collect_used_from_block(block, &mut used);

View file

@ -436,3 +436,85 @@ fn inline_removes_trivial() {
.any(|op| matches!(op, IrOp::Call(..)));
assert!(!has_call, "call to trivial function should be removed");
}
// ---------------------------------------------------------------------------
// Cross-block LoadImm liveness (regression for stale-and-result bug)
// ---------------------------------------------------------------------------
/// A `LoadImm(result, 0)` in a predecessor block whose only consumer
/// is a branch terminator in a *successor* block must not be dropped
/// by the const-folding pass. This is the shape the logical-`and`
/// short-circuit false path lowers to, and dropping the `LoadImm`
/// leaves the zero-page slot holding whichever value the previous
/// `and` evaluation wrote — which was observable in the platformer
/// example as spurious enemy-stomp bounces firing whenever coin 2
/// happened to be in its pickup window.
#[test]
fn const_fold_preserves_loadimm_used_by_sibling_branch() {
let cond = IrTemp(0);
let result = IrTemp(1);
let zero = IrTemp(2);
// Shape mirrors `lower_logical_and`: an entry block that branches
// on `cond`, a right-side block that writes `result` via an
// `Or(result, <true-case>, zero)`, a false-side block that only
// writes `LoadImm(result, 0)`, and a merge block whose terminator
// reads `result`.
let entry = IrBasicBlock {
label: "entry".to_string(),
ops: vec![IrOp::LoadImm(cond, 1)],
terminator: IrTerminator::Branch(cond, "right".to_string(), "false_path".to_string()),
};
let right = IrBasicBlock {
label: "right".to_string(),
ops: vec![IrOp::LoadImm(zero, 0), IrOp::Or(result, cond, zero)],
terminator: IrTerminator::Jump("end".to_string()),
};
// This is the block that previously tripped the bug: its
// `LoadImm(result, 0)` is the only op, and `result` is not used
// as a source operand anywhere *within* this block — but it is
// read by the `end` block's branch terminator.
let false_path = IrBasicBlock {
label: "false_path".to_string(),
ops: vec![IrOp::LoadImm(result, 0)],
terminator: IrTerminator::Jump("end".to_string()),
};
let end = IrBasicBlock {
label: "end".to_string(),
ops: vec![],
terminator: IrTerminator::Return(Some(result)),
};
let mut prog = IrProgram {
functions: vec![IrFunction {
name: "and_like".to_string(),
blocks: vec![entry, right, false_path, end],
locals: vec![],
param_count: 0,
has_return: true,
source_span: Span::new(0, 0, 0),
}],
globals: vec![],
rom_data: vec![],
states: vec![],
start_state: String::new(),
};
optimize(&mut prog);
// Find the false_path block post-optimization and verify that
// its LoadImm(result, 0) is still there.
let func = &prog.functions[0];
let fp = func
.blocks
.iter()
.find(|b| b.label == "false_path")
.expect("false_path block should survive as reachable");
assert!(
fp.ops
.iter()
.any(|op| matches!(op, IrOp::LoadImm(t, 0) if *t == result)),
"false-path LoadImm(result, 0) must survive optimization; block ops were {:?}",
fp.ops
);
}

View file

@ -1 +1 @@
7c8e60e4 132084
e8b3ea0b 132084

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Before After
Before After

View file

@ -12,6 +12,18 @@
// At stride=2 we end up with a 30 fps GIF that maps 1:1 to every
// other NES frame (NES runs at ~60 fps), which is the right
// tradeoff between smoothness and file size for a README demo.
//
// IMPORTANT: `docs/platformer.gif` is committed and embedded in the
// README. Any change to the compiler, the runtime, the harness, or
// `examples/platformer.ne` that alters the gameplay you see in the
// first ~6 seconds of the demo must be followed by
//
// node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif
//
// committed alongside the source change. The CI `emulator` job
// regenerates the gif and fails if the committed copy is stale —
// gifenc + jsnes are deterministic, so the freshly-rendered bytes
// byte-match a valid commit. See `.github/workflows/ci.yml`.
import { promises as fs } from "node:fs";
import path from "node:path";