diff --git a/README.md b/README.md index 463a7ee..fb44243 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ start Main | [`sfx_pitch_envelope.ne`](examples/sfx_pitch_envelope.ne) | Per-frame pulse `pitch:` arrays — the audio tick walks the pitch envelope in lockstep with the volume envelope and writes `$4002` on every NMI for a frequency-sweeping siren tone | | [`metasprite_demo.ne`](examples/metasprite_demo.ne) | `metasprite Hero { sprite: ..., dx: [...], dy: [...], frame: [...] }` declarative multi-tile groups — `draw Hero at: (x, y)` expands to one OAM slot per tile so 16×16 sprites stop needing four hand-written `draw` statements | | [`sprite_flicker_demo.ne`](examples/sprite_flicker_demo.ne) | `cycle_sprites` — rotates the OAM DMA start offset one slot per frame so scenes with more than 8 sprites on a scanline drop a *different* one each frame. Turns the NES's permanent sprite-dropout hardware symptom into visible flicker, which the eye reconstructs from adjacent frames. Pairs with the compile-time `W0109` warning and the debug-mode `debug.sprite_overflow()` / `debug.sprite_overflow_count()` telemetry for a three-layer defense against the 8-sprites-per-scanline limit. | -| [`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, a sprite-based status bar pinned above the scrolling playfield (coin icon + 2-digit score on the left, heart icon + lives counter on the right) that carries through into the death screen, cross-state life tracking that cycles GameOver → Playing while hearts last and back to Title when the last heart is spent, 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 | +| [`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, a background-nametable status bar pinned at the top of the viewport via a sprite-0 hit scroll split (coin icon + 2-digit score on the left, heart icon + lives counter on the right; digits and lives repaint via shadow-compare `nt_set`s so most frames append zero VRAM-ring entries), cross-state life tracking that cycles GameOver → Playing while hearts last and back to Title when the last heart is spent, 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 | | [`war.ne`](examples/war.ne) | **Production-quality card game** — a complete port of War split across `examples/war/*.ne`: title screen with a 0/1/2-player menu, animated deal, sliding face-up cards, deck-count HUD, "WAR!" tie-break with buried cards, victory screen with a fanfare, and a brisk 4/4 march on pulse 2. Pulls in nearly every NEScript subsystem (custom 88-tile sheet, felt nametable, 8-bit LFSR PRNG, queue-based decks, phase machine inside `Playing`, multiple sfx + music tracks). Building it surfaced seven compiler bugs, all fixed on the same branch — see `git log` for the details. | | [`feature_canary.ne`](examples/feature_canary.ne) | **Regression canary** — a minimal program that paints a green universal backdrop at frame 180 when every memory-affecting construct round-trips correctly, and flips to red if any check fails. The committed golden is green; any silent-drop regression (state-locals, uninit struct field writes, u16 high byte, array elements, `slow` placement, function return values) turns it red. Built after PR #31 to close the "goldens capture whatever happens, not what should happen" failure mode that let the state-local bug survive for a year. | | [`sha256.ne`](examples/sha256.ne) | **Interactive SHA-256 hasher** — an on-screen keyboard lets the player type up to 16 ASCII characters, and pressing ↵ runs a full FIPS 180-4 SHA-256 compression on the NES (64 rounds + 48-entry message-schedule expansion, all written in NEScript with inline-asm 32-bit primitives). The 64-character hex digest renders as sprites across eight 8-character rows at the bottom of the screen. Splits across `examples/sha256/*.ne` with a phased driver that runs four iterations per frame so the full hash finishes in well under a second; the jsnes golden captures `SHA-256("NES")` = `AE9145DB5CABC41FE34B54E34AF8881F462362EA20FD8F861B26532FFBB84E0D`. | diff --git a/docs/future-work.md b/docs/future-work.md index 765d69f..d48b25a 100644 --- a/docs/future-work.md +++ b/docs/future-work.md @@ -260,47 +260,6 @@ missing: call — would shave a few bytes more on programs with many deep handlers. -### `examples/platformer.ne` HUD — move from sprites to sprite-0 hit - -The platformer currently renders its status bar as five OAM -sprites re-emitted every frame inside `draw_hud()`. That works but -burns 5/64 OAM slots on static UI and forces the redraw path to -walk the HUD every tick even when `score` / `lives` haven't -changed. The clean upgrade is to paint the HUD into the top row of -the nametable and use a sprite-0 hit split (same pattern as -`examples/sprite_0_split_demo.ne`) to reset the X-scroll at the -HUD/playfield boundary so the rest of the screen can keep -scrolling freely. Target shape: - -1. Pre-paint row 0 of the nametable with `Bar` + glyph tiles via - an `on enter` one-shot that drops `nt_set` / `nt_fill_h` - writes into the VRAM ring — same shape as - `examples/hud_demo.ne`. -2. Place sprite 0 as a 1-pixel-wide invisible dot on the last - scanline of the HUD row; the PPU sets the sprite-0 hit flag - when rendering crosses it. -3. Poll the flag in `on frame` and latch `scroll(camera_x, 0)` - only after the hit, leaving the HUD painted with scroll=0 for - the top rows. -4. Replace the four in-frame `draw Tileset at: (…HUD…)` calls - with a shadow-compare `if score != last_score { nt_set(...) }` - pair (score digits) + `if lives != last_lives { nt_set(...) }` - (heart row). Most frames write nothing. -5. The palette row at metatile y=0 needs a dedicated sub-palette - so the HUD doesn't inherit the sky/brick/ground attribute - zones — add it to `palette_map` in row 0. -6. Shadow variables `last_score` / `last_lives` initialize to - `255` so the first frame paints unconditionally. - -Wins: 5 OAM slots back for enemies/particles, no per-frame HUD -draw cost, and the flagship demo actually exercises sprite-0 hit -in a real game (the standalone `sprite_0_split_demo.ne` stays as -the minimal pedagogical example). Still NROM — no mapper change. - -Blocker to track: the HUD reads `stomp_count` as a u8 decimal; -when this lands, plumb it through whatever digit-extraction path -replaces the current `tens_digit` / `ones_digit` helpers. - ### Cross-block temp live-range analysis The slot recycler is function-local per-block. Temps that flow across block diff --git a/docs/platformer.gif b/docs/platformer.gif index dc1b9bd..9d70070 100644 Binary files a/docs/platformer.gif and b/docs/platformer.gif differ diff --git a/examples/README.md b/examples/README.md index f9eca2e..b58093d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,7 +35,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `sfx_pitch_envelope.ne` | varying-pitch pulse SFX | A 16-frame frequency sweep written as a per-frame `pitch:` array on a Pulse-1 sfx. The compiler emits a separate `__sfx_pitch_` blob and gates the audio tick's pitch update path on the `__sfx_pitch_used` marker, so programs that stick to the scalar `pitch:` form still get byte-identical ROM output. | | `metasprite_demo.ne` | declarative multi-tile sprites | A 16×16 hero sprite split into a `metasprite Hero { sprite: Hero16, dx: [...], dy: [...], frame: [...] }` declaration. `draw Hero at: (px, py)` then expands to one `DrawSprite` op per tile in the IR lowering, each with its dx/dy added to the user's anchor point and the frame offset by the underlying sprite's base tile. The codegen needs no metasprite-specific support — it sees N regular draws and the OAM cursor allocator handles the slots. | | `nested_structs.ne` | nested struct fields, array struct fields, chained literals | Two `Hero` instances each carry a `Vec2` position and a `u8[4]` inventory. Exercises `hero.pos.x` chained access, `hero.inv[i]` array-field access, and chained struct-literal initializers (`Hero { pos: Vec2 { x: ..., y: ... }, inv: [...] }`). | -| `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, coin pickups, a sprite-based status bar pinned to the top of the viewport (coin icon + 2-digit score on the left, heart icon + lives counter on the right), cross-state life tracking that sends GameOver back to Title when the last heart is spent, 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`. | +| `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, coin pickups, a background-nametable status bar pinned at the top of the viewport via a sprite-0 hit scroll split (coin + 2-digit score on the left, heart + lives counter on the right; updates gated behind a `last_score` / `last_lives` shadow compare so most frames touch zero VRAM-ring bytes), cross-state life tracking that sends GameOver back to Title when the last heart is spent, 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`. | | `sprite_flicker_demo.ne` | `cycle_sprites`, 8-per-scanline hardware limit | Twelve sprites packed onto the same 4-pixel band — two more than the NES's 8-sprites-per-scanline hardware budget. The W0109 analyzer warning fires at compile time, and a `cycle_sprites` call at the end of `on frame` rotates the OAM DMA offset one slot per frame so the PPU drops a *different* sprite each frame. The permanent-dropout failure mode becomes visible flicker, which the eye reconstructs across frames. The classic NES technique used by Gradius, Battletoads, and every shmup that ever existed. | | `war.ne` | **production-quality card game**, multi-file source layout | A complete port of the card game War, split across `examples/war/*.ne` files and pulled in via `include` directives. Title screen with a 0/1/2-player menu (cursor sprite, blinking PRESS A, brisk 4/4 march on pulse 2), a 50-frame deal animation, a deep `Playing` state with an inner phase machine (`P_WAIT_A`/`P_FLY_A`/.../`P_WAR_BANNER`/`P_WAR_BURY`/`P_CHECK`), card-conserving queue-based decks built on a 200-iteration random-swap shuffle, a "WAR!" tie-break that buries 3+1 face-down cards per player and plays a noise-channel thump per bury, and a victory screen with the builtin fanfare. The first NEScript example to use a top-level file as a thin shell that `include`s ~12 component files; building it surfaced seven compiler bugs across the analyzer, IR lowerer, and codegen that were all fixed on the same branch (see `git log` for details). | | `pong.ne` | **production-quality Pong**, powerups, multi-ball, multi-file | A complete Pong game split across `examples/pong/*.ne`. CPU VS CPU / 1 PLAYER / 2 PLAYERS title menu with brisk pulse-2 title march and autopilot, smooth ball physics with wall and paddle bouncing, CPU AI that tracks the ball with a reaction lag and dead zone, three powerup types (LONG paddle for 5 hits, FAST ball on next hit, MULTI-ball on next hit spawning 3 balls) that bounce around the field and are caught by paddle AABB overlap, multi-ball scoring (each ball scores a point, round continues until last ball exits), inner phase machine (`P_SERVE`/`P_PLAY`/`P_POINT`), and a "PLAYER N WINS" victory screen with the builtin fanfare. First-to-7 wins. | diff --git a/examples/platformer.ne b/examples/platformer.ne index 886f6bb..76ba56f 100644 --- a/examples/platformer.ne +++ b/examples/platformer.ne @@ -57,7 +57,7 @@ palette Main { bg0: [white, gray, black] // sky / clouds bg1: [red, orange, dk_red] // bricks, Q-blocks bg2: [bright_green, dk_orange, brown] // grass, hills, bushes, dirt - bg3: [yellow, orange, dk_orange] // reserved + bg3: [red, orange, white] // HUD chrome (matches sp0) // Sprite sub-palette 0 — every `draw` uses this one slot // because the OAM attribute byte is always 0 in v0.1. @@ -316,7 +316,19 @@ sprite Tileset { ".aaaaaa.", "..aaaa..", "...aa...", - "........" + "........", + // tile 27: Sprite 0 anchor (single opaque pixel at row 7, + // col 3 — the sprite-0 hit fires on the last + // HUD scanline so the scroll split lands + // cleanly on the playfield below) + "........", + "........", + "........", + "........", + "........", + "........", + "........", + "...c...." ] } @@ -327,10 +339,18 @@ const TILE_PLAYER_BL: u8 = 3 const TILE_PLAYER_BR: u8 = 4 const TILE_ENEMY: u8 = 5 const TILE_COIN: u8 = 6 -// HUD glyphs (sprite-only — not referenced by the nametable). The -// digit tiles are contiguous so `TILE_DIGIT_0 + n` picks digit `n`. +// HUD glyphs. The digit tiles are contiguous so +// `TILE_DIGIT_0 + n` picks digit `n`. Digit + heart tiles are +// shared between sprite rendering (v0.1 sprites) and background +// rendering (the HUD row's `nt_set`s) via the matching `bg3` +// sub-palette. const TILE_DIGIT_0: u8 = 16 const TILE_HEART: u8 = 26 +// OAM slot-0 anchor — a 7-scanline-tall transparent sprite with +// one opaque pixel at row 7. Aligns with the coin tile's bottom +// row so sprite-0 hit fires at scanline 15, keeping the scroll +// split exactly at the HUD-row boundary. +const TILE_SPRITE0_ANCHOR: u8 = 27 // ── Background ────────────────────────────────────────────── // The 32×30 nametable is authored as ASCII art with a `legend` @@ -355,11 +375,32 @@ background Level { "*": 13 // bush "=": 7 // grass top "%": 8 // dirt + // HUD chrome glyphs — row 0 only. Mapped to the sprite + // CHR tiles we already declared so sprites and background + // render identical glyphs under the matching bg3 palette. + "o": 6 // coin icon (HUD score marker) + "h": 26 // heart icon (HUD lives marker) + "0": 16 // digit 0 (initial score tens/ones + fallback) + "3": 19 // digit 3 (initial lives) } + // Row 1 carries the status bar: coin + two score digits on the + // left, heart + single lives digit on the right. Row 0 stays + // sky because the jsnes golden harness (and many emulators) + // mask out the top 8 scanlines as overscan — anything painted + // there would never show in the committed PNG. Sprite 0 + // (painted over column 2's coin tile every frame) overlaps the + // `o` glyph's opaque pixels so the sprite-0 hit flag fires on + // a scanline inside the HUD row; `sprite_0_split(camera_x, 0)` + // in `on frame` picks up that hit and swaps the horizontal + // scroll from 0 to `camera_x` for the rest of the frame, + // pinning the HUD while the playfield scrolls underneath. The + // digits and heart glyph sit at fixed columns so shadow-compare + // `nt_set` writes can tick the score / lives readouts without + // repainting the whole row. map: [ "................................", - "................................", + "..o.00..................h.3.....", "................................", "................................", "................................", @@ -391,8 +432,9 @@ background Level { ] palette_map: [ - "0000000000000000", // metatile rows 0-5 → sky sub-palette - "0000000000000000", + "3333333333333333", // metatile row 0 (NT rows 0-1) + // → bg3 (HUD chrome: red / orange / white) + "0000000000000000", // metatile rows 1-5 → sky sub-palette "0000000000000000", "0000000000000000", "0000000000000000", @@ -488,23 +530,69 @@ var frame_tick: u8 = 0 // free-running frame counter var stomp_count: u8 = 0 // successful enemy stomps this life var lives: u8 = 3 // lives remaining; HUD heart readout -// ── HUD helpers ───────────────────────────────────────────── -// Paint the four-sprite status bar at the very top of the screen: -// a coin icon followed by a two-digit stomp tally on the left, and -// a heart icon followed by a single-digit lives counter on the -// right. Everything is drawn as OAM sprites (never the nametable) -// so the HUD stays pinned while the background scrolls under it. -// y = 16 is above the brick row, so the white digits read cleanly -// against the universal-sky backdrop. -fun draw_hud() { - // Score side (left): coin + tens + ones. - draw Tileset at: (16, 16) frame: TILE_COIN - draw Tileset at: (28, 16) frame: TILE_DIGIT_0 + (stomp_count / 10) - draw Tileset at: (36, 16) frame: TILE_DIGIT_0 + (stomp_count % 10) +// HUD shadow-compare cache. The initial nametable already paints +// "coin 00" / "heart 3" into row 0 with the bg3 palette, so the +// shadow values here start matching the on-screen glyphs — every +// frame where `stomp_count == last_score` skips the score repaint +// entirely, and likewise for lives. 255 would also force a +// guaranteed-initial-paint fallback on the first frame if the map +// ever drifts from these defaults; we use the real starting values +// so the harness's frame-180 golden doesn't capture a transient +// paint flicker. +var last_score: u8 = 0 +var last_lives: u8 = 3 - // Lives side (right): heart + digit. - draw Tileset at: (208, 16) frame: TILE_HEART - draw Tileset at: (224, 16) frame: TILE_DIGIT_0 + lives +// ── HUD helpers ───────────────────────────────────────────── +// Background-row HUD, pinned to the top of the viewport with a +// sprite-0 hit split instead of burning five OAM slots every +// frame. Two moving pieces: +// +// 1. The status glyphs — coin, two score digits, heart, lives +// digit — live in NT row 0 and are repainted via `nt_set` +// only when the backing state changes (`stomp_count`, +// `lives`). The initial map already contains the starting +// values so the first frame is already correct. +// +// 2. A single OAM sprite (slot 0) is drawn over the coin tile +// every frame. The PPU sets the sprite-0 hit flag when the +// sprite's opaque pixels overlap the coin tile's opaque +// pixels — guaranteed to fire on some scanline inside the +// HUD row — and a `sprite_0_split(camera_x, 0)` call picks +// up that hit to switch the horizontal scroll from 0 to +// `camera_x` for the rest of the frame. Row 0 stays pinned, +// everything below it scrolls with the camera. +fun draw_hud() { + // Sprite 0 — must be the FIRST draw of the frame so it lands + // in OAM slot 0. The anchor tile has one opaque pixel at row + // 7, col 3 (a single dot), which when positioned at (16, 7) + // in OAM renders at screen (19, 15) and overlaps column 3 of + // the coin tile's bottom row in NT row 1. That last-scanline + // overlap makes the sprite-0 hit flag set at scanline 15, so + // the `sprite_0_split(camera_x, 0)` call below can write + // `$2005` before the next HBLANK and the PPU latches the new + // horizontal scroll starting at scanline 16 — exactly the + // boundary between the HUD row and the playfield. The rest + // of the anchor tile is fully transparent, so the sprite is + // functionally invisible on top of the coin. + draw Tileset at: (16, 7) frame: TILE_SPRITE0_ANCHOR + + // Score tens / ones — shadow-compare so the VRAM ring only + // gets an entry when `stomp_count` actually moved. The + // comparison runs once per frame and most frames take the + // no-op path (score only ticks on a stomp). + if stomp_count != last_score { + last_score = stomp_count + nt_set(4, 1, TILE_DIGIT_0 + (stomp_count / 10)) + nt_set(5, 1, TILE_DIGIT_0 + (stomp_count % 10)) + } + + // Lives digit — same shadow-compare. Only fires on a life + // transition (death in `GameOver.on_enter`, refill in + // `Title.on_enter`). + if lives != last_lives { + last_lives = lives + nt_set(26, 1, TILE_DIGIT_0 + lives) + } } // ── Helper functions ──────────────────────────────────────── @@ -745,6 +833,13 @@ state Playing { resolve_enemy_hit(e2_sx) // ── Drawing ────────────────────────────────────────── + // `draw_hud()` runs FIRST so its coin sprite lands in OAM + // slot 0 — that's what makes the PPU's sprite-0 hit flag + // fire on the coin-tile overlap inside the HUD row, which + // we pick up with `sprite_0_split` at the end of the + // frame. Every other `draw` has to come after. + draw_hud() + // Enemies and coins are always drawn so the scene stays // coherent even on the fatal-contact frame. var ey: u8 = ENEMY_Y @@ -761,11 +856,6 @@ state Playing { draw Tileset at: (c1_sx, cy) frame: TILE_COIN draw Tileset at: (c2_sx, COIN_Y) frame: TILE_COIN - // Status bar (score tally on the left, lives on the right). - // Drawn as sprites so it stays pinned while the nametable - // scrolls under it. - draw_hud() - // 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". @@ -773,11 +863,15 @@ state Playing { 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) + // ── Sprite-0 scroll split ──────────────────────────── + // The VRAM-drain in NMI resets `$2005` to `(0, 0)` for + // the top of each frame; `sprite_0_split` busy-waits for + // the sprite-0 hit flag (fired mid-HUD-row where OAM + // slot 0's coin sprite overlaps the NT-row-1 coin tile) + // and then latches `(camera_x, 0)` for the remainder of + // the frame. Scanlines above the hit render the HUD + // pinned; scanlines below render the playfield scrolled. + sprite_0_split(camera_x, 0) // Fatal contact this frame → kick the state machine over // to GameOver on the next frame. @@ -804,25 +898,24 @@ state GameOver { on frame { linger += 1 + // Sync the HUD row first (sprite 0 + shadow-compare + // `nt_set` for the digits) so the tens/ones glyphs tick + // down when `lives` decrements in `on_enter`. No + // `sprite_0_split` call here — GameOver doesn't scroll, + // so NMI's scroll-reset keeps the whole frame at + // `(0, 0)` and the HUD row renders normally. The sprite- + // 0 hit flag fires harmlessly and stays latched until the + // next Playing frame clears it. + draw_hud() + // "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 + // middle of the screen. 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 - // Carry the status bar through the death screen so the - // final score and the remaining heart total are visible at - // the exact same position as in Playing — no awkward jump. - draw Tileset at: (16, 16) frame: TILE_COIN - draw Tileset at: (28, 16) frame: TILE_DIGIT_0 + (stomp_count / 10) - draw Tileset at: (36, 16) frame: TILE_DIGIT_0 + (stomp_count % 10) - draw Tileset at: (208, 16) frame: TILE_HEART - draw Tileset at: (224, 16) frame: TILE_DIGIT_0 + lives - // Auto-retry after ~1 s so the headless harness sees the // full loop. When the last life is spent, bounce back to // the Title screen instead (Title's `on enter` refills the diff --git a/examples/platformer.nes b/examples/platformer.nes index f73662e..c1fa82e 100644 Binary files a/examples/platformer.nes and b/examples/platformer.nes differ diff --git a/scripts/gen_platformer_tiles.rs b/scripts/gen_platformer_tiles.rs index 6eaaa8a..1c3a3ba 100644 --- a/scripts/gen_platformer_tiles.rs +++ b/scripts/gen_platformer_tiles.rs @@ -367,6 +367,30 @@ aaaaaaaa ...aa... ........", }, + // Sprite-0 hit anchor — a single opaque pixel at row 7, col 3, + // everything else transparent. Sits behind the HUD row's coin + // tile as OAM slot 0 in every `on frame`; its one opaque pixel + // aligns with column 3 of the coin's row 7 (`...bb...`), so + // the PPU sets the sprite-0 hit flag at scanline 15 — the + // last scanline of the HUD row. Writing `$2005` the moment + // that flag sets means the horizontal scroll flip takes + // effect at scanline 16 (the PPU latches horizontal scroll at + // the next HBLANK), which pins NT rows 0-1 at scroll=0 and + // lets scanlines 16+ render at `camera_x`. A sprite that + // hit on its *top* row (e.g. using the coin as sprite 0) + // would flip the scroll mid-HUD-row and smear the glyphs. + Tile { + name: "Sprite 0 anchor", + art: "\ +........ +........ +........ +........ +........ +........ +........ +...c....", + }, ]; // ── Named CHR tile indices used by the nametable layout below ── diff --git a/tests/emulator/goldens/platformer.audio.hash b/tests/emulator/goldens/platformer.audio.hash index 0f9524b..3794f35 100644 --- a/tests/emulator/goldens/platformer.audio.hash +++ b/tests/emulator/goldens/platformer.audio.hash @@ -1 +1 @@ -30938b26 132084 +94d0f184 132084 diff --git a/tests/emulator/goldens/platformer.png b/tests/emulator/goldens/platformer.png index 35dd658..e18cc7f 100644 Binary files a/tests/emulator/goldens/platformer.png and b/tests/emulator/goldens/platformer.png differ