mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 08:55:38 +00:00
examples: adopt the friendly asset syntax
Rewrites every example with non-trivial asset declarations to use
the pleasant QoL syntax introduced in the previous commit. Every
example still compiles to a byte-identical ROM (verified by a
temp-path diff before committing), so the committed `.nes` files
and the 23 emulator goldens are unchanged.
* platformer.ne — the centerpiece end-to-end demo:
- `palette Main` goes to grouped form with a shared
`universal: 0x22` (sky blue), one shared colour per
sub-palette, and named NES colours throughout; the
long-standing `$3F10` mirror-trap warning is now handled
by the parser and the manual pitfall comment is gone.
- `sprite Tileset` is 15 tiles of ASCII pixel art instead
of 240 bytes of inline hex.
- `background Level` uses a `legend { '.': 15, '#': 9, ... }`
block plus `map:` strings for the 32×30 nametable, and
`palette_map:` rows for the attribute table. The map
reads top-down like the rendered screen.
- SFX latch-once `pitch: 0x30` scalars + `envelope:` alias.
- `music Theme` uses note names + `tempo: 10` default.
* audio_demo.ne — scalar sfx pitches, `envelope:` alias, and a
note-name `C4, E4, G4, ...` music track.
* palette_and_background.ne — grouped CoolBlues / WarmReds
palettes with `universal: black` + named colours, plus
`legend` + `map:` tilemaps for the two backgrounds.
* sprites_and_palettes.ne — Arrow and Heart sprites rewritten
as `pixels:` ASCII art.
Along the way, two small parser extensions support the rewrites:
- `parse_pixel_art` now accepts `a/b/c` as aliases for `#/%/@`,
matching the vocabulary every NES editor (and our own
gen_platformer_tiles.rs generator) uses.
- `palette_map_to_attrs` allows up to 16 metatile rows (the
full attribute-table coverage, including the off-screen
bottom half) and auto-replicates row 14 → row 15 when only
15 rows are supplied so the visible bottom of the screen
gets consistent sub-palette assignments by default. The old
15-row cap couldn't match a hand-packed `0xAA` attribute
table for the last row; the platformer required this to
stay byte-identical.
`scripts/gen_platformer_tiles.rs` is updated to emit the new
syntax directly (pixel-art `pixels:` block + `legend`/`map:`/
`palette_map:` for the background), so regenerating the
platformer tiles stays a one-liner.
474 lib tests + 64 integration tests pass (3 new parser tests
for `palette_map:` 15/16/17 rows and the `abc` alias). All 23
emulator goldens still match pixel- and sample-for-sample.
https://claude.ai/code/session_01PzaSFj3VahDzxEYTKCESkz
This commit is contained in:
parent
48832ccb13
commit
ad951a835f
8 changed files with 676 additions and 353 deletions
|
|
@ -858,8 +858,13 @@ Rules:
|
||||||
- Every character in a `map:` string must be defined in the legend
|
- Every character in a `map:` string must be defined in the legend
|
||||||
(otherwise `E0201`).
|
(otherwise `E0201`).
|
||||||
- `palette_map:` rows are ≤ 16 digit characters (`0`-`3`, plus
|
- `palette_map:` rows are ≤ 16 digit characters (`0`-`3`, plus
|
||||||
`.` / space as a sub-palette 0 alias), no more than 15 rows. The
|
`.` / space as a sub-palette 0 alias). Up to 16 rows are
|
||||||
parser packs adjacent 2×2 metatile groups into the awkward
|
accepted: the first 15 cover the visible 240-scanline screen and
|
||||||
|
the optional 16th covers the off-screen half of the last
|
||||||
|
attribute row (the PPU still reads it). If exactly 15 rows are
|
||||||
|
supplied, the parser auto-replicates row 14 into row 15 so the
|
||||||
|
visible bottom edge of the screen gets consistent attribute
|
||||||
|
bytes. The packer handles the awkward
|
||||||
`(br<<6)|(bl<<4)|(tr<<2)|tl` attribute-byte layout for you.
|
`(br<<6)|(bl<<4)|(tr<<2)|tl` attribute-byte layout for you.
|
||||||
- Raw and tilemap forms are mutually exclusive per field
|
- Raw and tilemap forms are mutually exclusive per field
|
||||||
(`tiles:` vs `map:`, `attributes:` vs `palette_map:`).
|
(`tiles:` vs `map:`, `attributes:` vs `palette_map:`).
|
||||||
|
|
|
||||||
|
|
@ -35,43 +35,45 @@ game "Audio Demo" {
|
||||||
// ── User-declared sound effects ──
|
// ── User-declared sound effects ──
|
||||||
//
|
//
|
||||||
// An `sfx` block is a frame-accurate envelope for pulse 1. `pitch`
|
// An `sfx` block is a frame-accurate envelope for pulse 1. `pitch`
|
||||||
// latches the pulse period once on trigger; `volume` runs one entry
|
// is latched into `$4002` once on trigger — the v1 audio driver
|
||||||
// per frame, so the envelope length controls the effect duration.
|
// never updates the pulse period mid-effect — so a single scalar
|
||||||
// `duty` (0-3) picks the pulse waveform shape (2 = 50% square).
|
// byte is the natural form for the current pipeline. `envelope:`
|
||||||
|
// is a friendlier alias for `volume:`: both describe the per-frame
|
||||||
|
// volume ramp that shapes the effect. `duty` (0-3) picks the pulse
|
||||||
|
// waveform (2 = 50% square).
|
||||||
|
|
||||||
// A gentle rising chirp, longer than the builtin coin.
|
// A gentle rising chirp, longer than the builtin coin.
|
||||||
sfx LongCoin {
|
sfx LongCoin {
|
||||||
duty: 2
|
duty: 2
|
||||||
pitch: [0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50]
|
pitch: 0x50
|
||||||
volume: [15, 14, 13, 12, 11, 9, 7, 5, 3, 1]
|
envelope: [15, 14, 13, 12, 11, 9, 7, 5, 3, 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// A sharp two-part zap — quick high spike into silence.
|
// A sharp two-part zap — quick high spike into silence.
|
||||||
sfx Zap {
|
sfx Zap {
|
||||||
duty: 3
|
duty: 3
|
||||||
pitch: [0x20, 0x20, 0x20, 0x20, 0x20]
|
pitch: 0x20
|
||||||
volume: [15, 12, 8, 4, 1]
|
envelope: [15, 12, 8, 4, 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── User-declared music tracks ──
|
// ── User-declared music tracks ──
|
||||||
//
|
//
|
||||||
// A `music` block is a flat list of `(pitch, duration)` note pairs.
|
// Notes are written in note-name form (`C4`, `E4`, `G4`, …) with a
|
||||||
// Pitch 0 = rest; 1-60 = period table index (C1..B5, middle C = 37).
|
// default frames-per-note set by `tempo:`. Any note can override
|
||||||
// Duration is in frames (so at 60 fps, 30 = half second per note).
|
// the default by trailing the name with an explicit duration
|
||||||
// Music loops by default; set `repeat: false` for one-shot cues.
|
// (e.g. `G4 40` holds twice as long). `rest` (or the alias `_`) is
|
||||||
|
// a silent beat. Use the legacy `37, 20, 41, 20, ...` pair form by
|
||||||
|
// leaving `tempo:` out if you'd rather pick pitches by index.
|
||||||
|
|
||||||
// A cheerful four-note looping theme.
|
// A cheerful four-note looping theme — every note is one-third of
|
||||||
|
// a second long, and the whole loop is 120 frames = 2 seconds.
|
||||||
music Theme {
|
music Theme {
|
||||||
duty: 2
|
duty: 2
|
||||||
volume: 10
|
volume: 10
|
||||||
repeat: true
|
repeat: true
|
||||||
|
tempo: 20
|
||||||
notes: [
|
notes: [
|
||||||
37, 20, // C4
|
C4, E4, G4, C5, G4, E4
|
||||||
41, 20, // E4
|
|
||||||
44, 20, // G4
|
|
||||||
49, 20, // C5
|
|
||||||
44, 20, // G4
|
|
||||||
41, 20, // E4
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,39 +24,39 @@ game "Palette + BG Demo" {
|
||||||
|
|
||||||
// ── Palettes ────────────────────────────────────────────────
|
// ── Palettes ────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Each palette is 32 bytes laid out in the standard NES order:
|
// Both palettes are authored in grouped form: one shared
|
||||||
// $00-$0F background palettes 0-3 (4 colours each)
|
// `universal:` colour feeds every sub-palette's index-0 slot,
|
||||||
// $10-$1F sprite palettes 0-3 (4 colours each, $10/$14/$18/$1C
|
// which auto-fixes the `$3F10/$3F14/$3F18/$3F1C` PPU mirror
|
||||||
// mirror the bg slots)
|
// trap (the parser handles the packing). Each `bgN` / `spN`
|
||||||
|
// field only needs three colours — the universal is prepended.
|
||||||
//
|
//
|
||||||
// `CoolBlues` uses deep blue background tiles with pale highlights;
|
// Named NES colours resolve to master-palette indices via the
|
||||||
// `WarmReds` swaps them for red/orange tones. Every 4 bytes is a
|
// curated name table; see `docs/language-guide.md` for the full
|
||||||
// sub-palette.
|
// list. Hex byte literals (`0x01`, `0x14`, …) still work if you
|
||||||
|
// need a colour that doesn't have a name.
|
||||||
|
|
||||||
palette CoolBlues {
|
palette CoolBlues {
|
||||||
colors: [
|
universal: black
|
||||||
0x0F, 0x01, 0x11, 0x21, // bg palette 0: black, deep blue, sky, pale
|
bg0: [dk_blue, blue, sky_blue] // deep blue highlights
|
||||||
0x0F, 0x02, 0x12, 0x22, // bg palette 1
|
bg1: [indigo, royal_blue, periwinkle] // cool purples
|
||||||
0x0F, 0x0C, 0x1C, 0x2C, // bg palette 2
|
bg2: [dk_cyan, cyan, lt_cyan] // aquas
|
||||||
0x0F, 0x0B, 0x1B, 0x2B, // bg palette 3
|
bg3: [dk_teal, teal, lt_teal] // teal accents
|
||||||
0x0F, 0x01, 0x11, 0x21, // sprite palette 0
|
sp0: [dk_blue, blue, sky_blue] // matches bg0
|
||||||
0x0F, 0x16, 0x27, 0x30, // sprite palette 1
|
sp1: [red, orange, white] // warm accent sprites
|
||||||
0x0F, 0x14, 0x24, 0x34, // sprite palette 2
|
sp2: [magenta, lt_magenta, pale_pink] // magenta sprites
|
||||||
0x0F, 0x0B, 0x1B, 0x2B // sprite palette 3
|
sp3: [dk_teal, teal, lt_teal] // matches bg3
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
palette WarmReds {
|
palette WarmReds {
|
||||||
colors: [
|
universal: black
|
||||||
0x0F, 0x06, 0x16, 0x26, // bg palette 0: black, dark red, red, peach
|
bg0: [dk_red, red, lt_red] // fiery warm bg
|
||||||
0x0F, 0x07, 0x17, 0x27, // bg palette 1
|
bg1: [brown, dk_orange, orange] // autumnal
|
||||||
0x0F, 0x08, 0x18, 0x28, // bg palette 2
|
bg2: [dk_olive, olive, yellow] // muted yellows
|
||||||
0x0F, 0x09, 0x19, 0x29, // bg palette 3
|
bg3: [dk_green, green, lt_green] // greens for contrast
|
||||||
0x0F, 0x06, 0x16, 0x26, // sprite palette 0
|
sp0: [dk_red, red, lt_red] // matches bg0
|
||||||
0x0F, 0x16, 0x27, 0x30, // sprite palette 1
|
sp1: [red, orange, white] // highlight sprites
|
||||||
0x0F, 0x14, 0x24, 0x34, // sprite palette 2
|
sp2: [magenta, lt_magenta, pale_pink] // pinks
|
||||||
0x0F, 0x0B, 0x1B, 0x2B // sprite palette 3
|
sp3: [dk_teal, teal, lt_teal] // cool accent
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Backgrounds ─────────────────────────────────────────────
|
// ── Backgrounds ─────────────────────────────────────────────
|
||||||
|
|
@ -74,40 +74,46 @@ palette WarmReds {
|
||||||
// top-left to bottom-right so the swap is visually obvious.
|
// top-left to bottom-right so the swap is visually obvious.
|
||||||
|
|
||||||
background TitleScreen {
|
background TitleScreen {
|
||||||
tiles: [
|
// Both backgrounds resolve to an all-zero nametable for this
|
||||||
// Row 14 (offset 14*32 = 448): a ribbon of tile 0
|
// demo — the visual difference between them comes from the
|
||||||
// across columns 4..28.
|
// palette swap triggered alongside `load_background`. We still
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
// author them with a `legend` + `map:` block here so the demo
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
// exercises the pleasant syntax the same way a real game
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
// would. The '.' legend entry alone is enough to fill an
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
// all-tile-0 background; every row is padded to 32 columns
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
// automatically by the parser.
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
legend { ".": 0 }
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
map: [
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
"................................", // row 0
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
"................................", // row 1
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
"................................", // row 2
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
"................................", // row 3
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
"................................", // row 4
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
"................................", // row 5
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
"................................", // row 6
|
||||||
// Row 14
|
"................................", // row 7
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
"................................", // row 8
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
|
"................................", // row 9
|
||||||
|
"................................", // row 10
|
||||||
|
"................................", // row 11
|
||||||
|
"................................", // row 12
|
||||||
|
"................................", // row 13
|
||||||
|
"................................", // row 14 (ribbon)
|
||||||
|
"................................" // row 15
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
background StageOne {
|
background StageOne {
|
||||||
tiles: [
|
// StageOne's visual difference comes from the palette swap —
|
||||||
// A few scattered tile-0s across the first 4 rows so the
|
// the tile grid is still the built-in smiley everywhere. We
|
||||||
// background visibly differs from TitleScreen after the
|
// only declare 4 rows to prove the parser's zero-padding
|
||||||
// swap. Remaining rows are zero-padded and render as
|
// still works with the new tilemap form.
|
||||||
// tile 0 anyway — the visual difference comes from the
|
legend { ".": 0 }
|
||||||
// palette swap triggered alongside.
|
map: [
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
"................................",
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
"................................",
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
"................................",
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
|
"................................"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,54 +32,34 @@ game "Platformer" {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Palette ──────────────────────────────────────────────────
|
// ── Palette ──────────────────────────────────────────────────
|
||||||
// The background palette drives the sky / hill / ground look.
|
// Authored in grouped form: one shared `universal:` colour fills
|
||||||
// Each sub-palette reserves index 0 for the shared background
|
// every sub-palette's index-0 slot automatically. That single
|
||||||
// colour (sky blue $22), plus three working colours.
|
// declaration is enough to dodge the notorious `$3F10/$3F14/$3F18/
|
||||||
// bg 0 (sky region): sky blue, white, light gray, black
|
// $3F1C` PPU mirror trap — when a program writes 8 distinct
|
||||||
// bg 1 (air/blocks row): sky blue, red, peach, dark red
|
// "index 0" bytes sequentially, the last write clobbers the
|
||||||
// bg 2 (ground row): sky blue, lime green, dark green, dark brown
|
// shared background colour and the screen turns the wrong colour
|
||||||
// bg 3 (unused reserve): sky blue, yellow, orange, gold
|
// (or all-black). Using one shared universal everywhere is what
|
||||||
// The sprite sub-palette 0 (used by every `draw` statement — the
|
// every shipped NES game does; the parser handles it for us.
|
||||||
// OAM attribute byte is always 0 in the current NEScript
|
//
|
||||||
// runtime) carries the hero's red/peach/white body colours.
|
// `0x22` is the lavender-blue NES master palette slot Super Mario
|
||||||
// NOTE: the first byte of every sub-palette must match. The PPU
|
// Bros uses for its iconic sky. The colour names map to the
|
||||||
// mirrors $3F10/$3F14/$3F18/$3F1C onto $3F00/$3F04/$3F08/$3F0C, so
|
// curated set documented in `docs/language-guide.md`.
|
||||||
// writing 8 distinct "index 0" bytes ends up clobbering the
|
|
||||||
// background universal colour with the last write. The canonical
|
|
||||||
// fix — and what every NES game does — is to use the universal
|
|
||||||
// background colour ($22 sky blue here) in all 8 slots. For sprite
|
|
||||||
// palettes, index 0 is always transparent regardless of the stored
|
|
||||||
// value, so this costs nothing visually.
|
|
||||||
palette Main {
|
palette Main {
|
||||||
colors: [
|
universal: 0x22 // sky blue (NES $22)
|
||||||
// bg palette 0 — sky / clouds
|
|
||||||
// 0 = sky blue (universal bg)
|
// Background sub-palettes
|
||||||
// 1 = white
|
bg0: [white, gray, black] // sky / clouds
|
||||||
// 2 = light gray
|
bg1: [red, orange, dk_red] // bricks, Q-blocks
|
||||||
// 3 = black
|
bg2: [bright_green, dk_orange, brown] // grass, hills, bushes, dirt
|
||||||
0x22, 0x30, 0x10, 0x0F,
|
bg3: [yellow, orange, dk_orange] // reserved
|
||||||
// bg palette 1 — bricks, Q-blocks
|
|
||||||
// 1 = red, 2 = peach, 3 = dark red
|
// Sprite sub-palette 0 — every `draw` uses this one slot
|
||||||
0x22, 0x16, 0x27, 0x06,
|
// because the OAM attribute byte is always 0 in v0.1.
|
||||||
// bg palette 2 — grass, hills, bushes, dirt
|
// 1 = red cap, 2 = orange skin, 3 = white highlight.
|
||||||
// 1 = light green
|
sp0: [red, orange, white]
|
||||||
// 2 = light brown
|
sp1: [brown, dk_orange, orange] // reserved
|
||||||
// 3 = dark brown
|
sp2: [neon_green, bright_green, white] // reserved
|
||||||
0x22, 0x1A, 0x17, 0x07,
|
sp3: [yellow, olive, cream] // reserved
|
||||||
// bg palette 3 — unused but the mirroring still writes here
|
|
||||||
0x22, 0x28, 0x27, 0x17,
|
|
||||||
// sprite palette 0 — hero / enemy / coin
|
|
||||||
// (OAM attribute is always 0 in the current runtime so
|
|
||||||
// every sprite uses this one sub-palette)
|
|
||||||
// 1 = red cap, 2 = peach skin, 3 = white
|
|
||||||
0x22, 0x16, 0x27, 0x30,
|
|
||||||
// sprite palette 1 — reserved
|
|
||||||
0x22, 0x07, 0x17, 0x27,
|
|
||||||
// sprite palette 2 — reserved
|
|
||||||
0x22, 0x2A, 0x1A, 0x30,
|
|
||||||
// sprite palette 3 — reserved
|
|
||||||
0x22, 0x28, 0x18, 0x38
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tileset ──────────────────────────────────────────────────
|
// ── Tileset ──────────────────────────────────────────────────
|
||||||
|
|
@ -89,38 +69,149 @@ palette Main {
|
||||||
// same declaration drives the player, enemies, coins, and every
|
// same declaration drives the player, enemies, coins, and every
|
||||||
// background tile after tile 0. Tile 0 is reserved by the linker
|
// background tile after tile 0. Tile 0 is reserved by the linker
|
||||||
// for the built-in smiley — the background leaves it unreferenced.
|
// for the built-in smiley — the background leaves it unreferenced.
|
||||||
|
//
|
||||||
|
// All 15 tiles are authored as 8×8 ASCII pixel art and stacked
|
||||||
|
// vertically (1 tile wide × 15 tiles tall) so the parser splits
|
||||||
|
// them into consecutive CHR tile indices in reading order. The
|
||||||
|
// art uses the `.abc` vocabulary (`. = 0`, `a = 1`, `b = 2`,
|
||||||
|
// `c = 3`); regenerate with `cargo run --bin gen_platformer_tiles`.
|
||||||
sprite Tileset {
|
sprite Tileset {
|
||||||
chr: [
|
pixels: [
|
||||||
// tile 1: Player head L
|
// tile 1: Player head L
|
||||||
0x3F, 0x7F, 0x70, 0x61, 0xCF, 0xCD, 0xC0, 0xC0, 0x00, 0x00, 0x0F, 0x1F, 0x3F, 0x3F, 0x3F, 0x3F,
|
"..aaaaaa",
|
||||||
|
".aaaaaaa",
|
||||||
|
".aaabbbb",
|
||||||
|
".aabbbbc",
|
||||||
|
"aabbcccc",
|
||||||
|
"aabbccbc",
|
||||||
|
"aabbbbbb",
|
||||||
|
"aabbbbbb",
|
||||||
// tile 2: Player head R
|
// tile 2: Player head R
|
||||||
0xFC, 0xFE, 0x0E, 0x83, 0xF8, 0x4C, 0x00, 0x00, 0x00, 0x00, 0xF0, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF,
|
"aaaaaa..",
|
||||||
|
"aaaaaaa.",
|
||||||
|
"bbbbaaa.",
|
||||||
|
"cbbbbbaa",
|
||||||
|
"cccccbbb",
|
||||||
|
"bcbbccbb",
|
||||||
|
"bbbbbbbb",
|
||||||
|
"bbbbbbbb",
|
||||||
// tile 3: Player body L
|
// tile 3: Player body L
|
||||||
0x7F, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x10, 0x18, 0x00, 0x7F, 0x7F, 0x67, 0x07,
|
".aaaaaaa",
|
||||||
|
"aaacaaaa",
|
||||||
|
"aaaccaaa",
|
||||||
|
"aaaaaaaa",
|
||||||
|
".bbbbbbb",
|
||||||
|
".bbbbbbb",
|
||||||
|
".bb..bbb",
|
||||||
|
"aaa..bbb",
|
||||||
// tile 4: Player body R
|
// tile 4: Player body R
|
||||||
0xFE, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x07, 0x00, 0x08, 0x18, 0x00, 0xFE, 0xFE, 0xE6, 0xE0,
|
"aaaaaaa.",
|
||||||
|
"aaaacaaa",
|
||||||
|
"aaaccaaa",
|
||||||
|
"aaaaaaaa",
|
||||||
|
"bbbbbbb.",
|
||||||
|
"bbbbbbb.",
|
||||||
|
"bbb..bb.",
|
||||||
|
"bbb..aaa",
|
||||||
// tile 5: Enemy
|
// tile 5: Enemy
|
||||||
0x3C, 0x7E, 0x76, 0xFF, 0x99, 0x7E, 0xBD, 0x99, 0x00, 0x00, 0x18, 0x38, 0x7E, 0x00, 0x00, 0x00,
|
"..aaaa..",
|
||||||
|
".aaaaaa.",
|
||||||
|
".aacbaa.",
|
||||||
|
"aacccaaa",
|
||||||
|
"abbccbba",
|
||||||
|
".aaaaaa.",
|
||||||
|
"a.aaaa.a",
|
||||||
|
"a..aa..a",
|
||||||
// tile 6: Coin
|
// tile 6: Coin
|
||||||
0x00, 0x00, 0x18, 0x24, 0x24, 0x18, 0x00, 0x00, 0x18, 0x3C, 0x7E, 0x7E, 0x7E, 0x7E, 0x3C, 0x18,
|
"...bb...",
|
||||||
|
"..bbbb..",
|
||||||
|
".bbccbb.",
|
||||||
|
".bcbbcb.",
|
||||||
|
".bcbbcb.",
|
||||||
|
".bbccbb.",
|
||||||
|
"..bbbb..",
|
||||||
|
"...bb...",
|
||||||
// tile 7: Grass top
|
// tile 7: Grass top
|
||||||
0xFF, 0xBB, 0xFF, 0xFF, 0xFF, 0xFF, 0xBD, 0xFF, 0x00, 0x00, 0x00, 0x6A, 0xF7, 0xFF, 0xFF, 0xFF,
|
"aaaaaaaa",
|
||||||
|
"a.aaa.aa",
|
||||||
|
"aaaaaaaa",
|
||||||
|
"accacaca",
|
||||||
|
"ccccaccc",
|
||||||
|
"cccccccc",
|
||||||
|
"cbccccbc",
|
||||||
|
"cccccccc",
|
||||||
// tile 8: Dirt
|
// tile 8: Dirt
|
||||||
0xFF, 0xBD, 0xFF, 0xF7, 0xFF, 0xBD, 0xF7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
"cccccccc",
|
||||||
|
"cbccccbc",
|
||||||
|
"cccccccc",
|
||||||
|
"ccccbccc",
|
||||||
|
"cccccccc",
|
||||||
|
"cbccccbc",
|
||||||
|
"ccccbccc",
|
||||||
|
"cccccccc",
|
||||||
// tile 9: Brick
|
// tile 9: Brick
|
||||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x82, 0x82, 0x82, 0xFF, 0x10, 0x10, 0x10,
|
"cccccccc",
|
||||||
|
"caaaaaca",
|
||||||
|
"caaaaaca",
|
||||||
|
"caaaaaca",
|
||||||
|
"cccccccc",
|
||||||
|
"aaacaaaa",
|
||||||
|
"aaacaaaa",
|
||||||
|
"aaacaaaa",
|
||||||
// tile 10: Cloud L
|
// tile 10: Cloud L
|
||||||
0x00, 0x1C, 0x3E, 0x7F, 0x7F, 0x1F, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x3E, 0x07,
|
"........",
|
||||||
|
"...aaa..",
|
||||||
|
"..aaaaa.",
|
||||||
|
".aaaaaaa",
|
||||||
|
".aaaaaaa",
|
||||||
|
".bbaaaaa",
|
||||||
|
"..bbbbba",
|
||||||
|
".....bbb",
|
||||||
// tile 11: Cloud R
|
// tile 11: Cloud R
|
||||||
0x00, 0x70, 0xF8, 0xFE, 0xFE, 0xF8, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x7C, 0xE0,
|
"........",
|
||||||
|
".aaa....",
|
||||||
|
"aaaaa...",
|
||||||
|
"aaaaaaa.",
|
||||||
|
"aaaaaaa.",
|
||||||
|
"aaaaabb.",
|
||||||
|
"abbbbb..",
|
||||||
|
"bbb.....",
|
||||||
// tile 12: Hill
|
// tile 12: Hill
|
||||||
0x00, 0x0C, 0x1E, 0x3F, 0x2F, 0x4B, 0x75, 0xC9, 0x00, 0x00, 0x00, 0x00, 0x10, 0x34, 0x0A, 0x36,
|
"........",
|
||||||
|
"....aa..",
|
||||||
|
"...aaaa.",
|
||||||
|
"..aaaaaa",
|
||||||
|
"..abaaaa",
|
||||||
|
".abbabaa",
|
||||||
|
".aaababa",
|
||||||
|
"aabbabba",
|
||||||
// tile 13: Bush
|
// tile 13: Bush
|
||||||
0x00, 0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x02, 0x02, 0x06, 0x06, 0x55,
|
"........",
|
||||||
|
"...aa...",
|
||||||
|
"..aaaa..",
|
||||||
|
".aaaaac.",
|
||||||
|
"aaaaaaca",
|
||||||
|
"aaaaacca",
|
||||||
|
"aaaaacca",
|
||||||
|
"acacacac",
|
||||||
// tile 14: Q Block
|
// tile 14: Q Block
|
||||||
0xFF, 0xFF, 0xC3, 0xDB, 0xD3, 0xC3, 0xFF, 0xFF, 0xFF, 0x81, 0xBD, 0xBD, 0xBD, 0xBD, 0x81, 0xFF,
|
"cccccccc",
|
||||||
|
"caaaaaac",
|
||||||
|
"cabbbbac",
|
||||||
|
"cabccbac",
|
||||||
|
"cabcbbac",
|
||||||
|
"cabbbbac",
|
||||||
|
"caaaaaac",
|
||||||
|
"cccccccc",
|
||||||
// tile 15: Sky (blank)
|
// tile 15: Sky (blank)
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
"........",
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
"........"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,142 +224,121 @@ const TILE_ENEMY: u8 = 5
|
||||||
const TILE_COIN: u8 = 6
|
const TILE_COIN: u8 = 6
|
||||||
|
|
||||||
// ── Background ──────────────────────────────────────────────
|
// ── Background ──────────────────────────────────────────────
|
||||||
// Full-screen 32×30 nametable showing a static level view that
|
// The 32×30 nametable is authored as ASCII art with a `legend`
|
||||||
// horizontal scrolling pans across. The attribute table splits
|
// block naming each tile by a single character. Horizontal
|
||||||
// the screen into three palette regions (sky/blocks/ground) so
|
// scrolling pans this single nametable via `scroll()` so it only
|
||||||
// the grass and hills pick up the right colours even without
|
// needs to look interesting at any X offset.
|
||||||
// per-tile attribute churn.
|
//
|
||||||
|
// `palette_map:` is the friendlier alternative to a 64-byte
|
||||||
|
// hand-packed attribute table: 16×15 metatile entries, one
|
||||||
|
// digit per 16×16 metatile, and the parser packs the 2-bit
|
||||||
|
// `(br<<6)|(bl<<4)|(tr<<2)|tl` quadrants automatically. The
|
||||||
|
// three palette regions are sky (`0`), brick row (`1`), and
|
||||||
|
// ground (`2`).
|
||||||
background Level {
|
background Level {
|
||||||
tiles: [
|
legend {
|
||||||
// rows 0-4: clean sky
|
".": 15 // sky (blank)
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"<": 10 // cloud left half
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
">": 11 // cloud right half
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"#": 9 // brick
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"Q": 14 // question block
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"^": 12 // hill silhouette
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"*": 13 // bush
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"=": 7 // grass top
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"%": 8 // dirt
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
}
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
|
||||||
// row 5: clouds at x=4..5 and x=20..21
|
map: [
|
||||||
15, 15, 15, 15, 10, 11, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 10, 11, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
// row 6: cloud at x=12..13
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 10, 11, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
// rows 7-14: more clean sky
|
"....<>..............<>..........",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"............<>..................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"......##Q#........###...........",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"................................",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"..^^^.................^^^^......",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"..........***..............**...",
|
||||||
// row 15: brick/Q-block platform left, brick platform right
|
"================================",
|
||||||
15, 15, 15, 15, 15, 15, 9, 9, 14, 9, 15, 15, 15, 15, 15, 15,
|
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#%",
|
||||||
15, 15, 9, 9, 9, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"%%%%%#%%%%%%%%%%%%#%%%%%%%%%%%%%",
|
||||||
// rows 16-19: sky
|
"%%%%%%%%%%%#%%%%%%%%%%%%#%%%%%%%",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%",
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
|
||||||
// row 20: hills (3 on left, 4 on right)
|
|
||||||
15, 15, 12, 12, 12, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
|
||||||
15, 15, 15, 15, 15, 15, 12, 12, 12, 12, 15, 15, 15, 15, 15, 15,
|
|
||||||
// row 21: bushes above the grass line
|
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 13, 13, 13, 15, 15, 15,
|
|
||||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 13, 13, 15, 15, 15,
|
|
||||||
// row 22: grass top (full width)
|
|
||||||
7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
|
|
||||||
7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
|
|
||||||
// rows 23-29: dirt with a few buried bricks for texture
|
|
||||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
|
||||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 8,
|
|
||||||
8, 8, 8, 8, 8, 9, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
|
||||||
8, 8, 9, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
|
||||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 8, 8, 8, 8,
|
|
||||||
8, 8, 8, 8, 8, 8, 8, 8, 9, 8, 8, 8, 8, 8, 8, 8,
|
|
||||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
|
||||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
|
||||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
|
||||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
|
||||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
|
||||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
|
||||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
|
||||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8
|
|
||||||
]
|
]
|
||||||
attributes: [
|
|
||||||
// 8×8 attribute grid, one byte per 32×32-px metatile group.
|
palette_map: [
|
||||||
// Each byte packs 4 identical 2-bit quadrants selecting a
|
"0000000000000000", // metatile rows 0-5 → sky sub-palette
|
||||||
// sub-palette (0..3):
|
"0000000000000000",
|
||||||
// 0x00 = sub-palette 0 (sky / clouds)
|
"0000000000000000",
|
||||||
// 0x55 = sub-palette 1 (bricks / Q-blocks)
|
"0000000000000000",
|
||||||
// 0xAA = sub-palette 2 (grass / hills / dirt)
|
"0000000000000000",
|
||||||
//
|
"0000000000000000",
|
||||||
// Row mapping: ay 0-2 (nt rows 0-11) = sky, ay 3 (nt rows
|
"1111111111111111", // metatile rows 6-7 → brick sub-palette
|
||||||
// 12-15) = brick row, ay 4 (nt rows 16-19) = more sky, ay
|
"1111111111111111",
|
||||||
// 5-7 (nt rows 20-29) = ground.
|
"0000000000000000", // metatile rows 8-9 → sky again
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
"0000000000000000",
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
"2222222222222222", // metatile rows 10-15 → ground sub-palette
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
"2222222222222222", // (rows 15 sits off-screen but the PPU
|
||||||
0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55,
|
"2222222222222222", // still reads its attribute byte, so
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
"2222222222222222", // we emit it explicitly to match the
|
||||||
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA,
|
"2222222222222222", // hand-packed attribute table the
|
||||||
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA,
|
"2222222222222222" // old form was using.)
|
||||||
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audio ────────────────────────────────────────────────────
|
// ── Audio ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// SFX: scalar `pitch:` matches the v1 driver's latch-once
|
||||||
|
// behaviour — pulse 1's period is sampled exactly once when the
|
||||||
|
// effect triggers and never updated, so a single byte is the
|
||||||
|
// natural form for the current pipeline. `envelope:` is the
|
||||||
|
// friendlier alias for `volume:`.
|
||||||
|
|
||||||
// Custom boingy jump sound — descending pitch arc on pulse 1.
|
// Custom boingy jump sound — fixed-pitch arc on pulse 1.
|
||||||
sfx Boing {
|
sfx Boing {
|
||||||
duty: 2
|
duty: 2
|
||||||
pitch: [0x30, 0x38, 0x40, 0x48, 0x50, 0x58, 0x60]
|
pitch: 0x30
|
||||||
volume: [12, 11, 10, 9, 7, 5, 2]
|
envelope: [12, 11, 10, 9, 7, 5, 2]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom coin ding — short rising blip.
|
// Custom coin ding — short rising blip.
|
||||||
sfx Ding {
|
sfx Ding {
|
||||||
duty: 2
|
duty: 2
|
||||||
pitch: [0x20, 0x20, 0x20, 0x20]
|
pitch: 0x20
|
||||||
volume: [14, 12, 8, 3]
|
envelope: [14, 12, 8, 3]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom looping underworld theme — playful four-bar loop on
|
// Custom looping underworld theme — playful four-bar loop on
|
||||||
// pulse 2 that plays while the player is alive.
|
// pulse 2 that plays while the player is alive. Note names
|
||||||
|
// (C4 / E4 / etc.) plus a default `tempo:` so each note only
|
||||||
|
// has to spell its frame count when it deviates from the beat.
|
||||||
music Theme {
|
music Theme {
|
||||||
duty: 2
|
duty: 2
|
||||||
volume: 9
|
volume: 9
|
||||||
repeat: true
|
repeat: true
|
||||||
|
tempo: 10
|
||||||
notes: [
|
notes: [
|
||||||
37, 10, // C4
|
C4, E4, G4, C5, G4, E4,
|
||||||
41, 10, // E4
|
D4 15,
|
||||||
44, 10, // G4
|
rest 5,
|
||||||
49, 10, // C5
|
B3, E4, G4,
|
||||||
44, 10, // G4
|
C5 20,
|
||||||
41, 10, // E4
|
rest 10
|
||||||
39, 15, // D4
|
|
||||||
0, 5, // rest
|
|
||||||
36, 10, // B3
|
|
||||||
41, 10, // E4
|
|
||||||
44, 10, // G4
|
|
||||||
49, 20, // C5
|
|
||||||
0, 10 // rest
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,38 @@ game "Asset Demo" {
|
||||||
mapper: NROM
|
mapper: NROM
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define a sprite with inline CHR tile data (16 bytes = one 8x8 tile)
|
// Define a sprite with ASCII pixel art. Each character maps to a
|
||||||
// This is a simple arrow pointing right
|
// 2-bit palette index: `.` = 0 (transparent), `#` = 1, `%` = 2,
|
||||||
|
// `@` = 3. The parser handles the 2-bitplane CHR encoding, so we
|
||||||
|
// never touch hex bytes by hand.
|
||||||
|
//
|
||||||
|
// Arrow — a right-facing arrow in palette-index 1.
|
||||||
sprite Arrow {
|
sprite Arrow {
|
||||||
chr: [0x18, 0x1C, 0xFE, 0xFF, 0xFF, 0xFE, 0x1C, 0x18,
|
pixels: [
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
|
"...##...",
|
||||||
|
"...###..",
|
||||||
|
"#######.",
|
||||||
|
"########",
|
||||||
|
"########",
|
||||||
|
"#######.",
|
||||||
|
"...###..",
|
||||||
|
"...##..."
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define a sprite with a heart shape
|
// Heart — a full-colour heart in palette-index 3 (the brightest
|
||||||
|
// shade, `@`).
|
||||||
sprite Heart {
|
sprite Heart {
|
||||||
chr: [0x66, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00,
|
pixels: [
|
||||||
0x66, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00]
|
".@@..@@.",
|
||||||
|
"@@@@@@@@",
|
||||||
|
"@@@@@@@@",
|
||||||
|
"@@@@@@@@",
|
||||||
|
".@@@@@@.",
|
||||||
|
"..@@@@..",
|
||||||
|
"...@@...",
|
||||||
|
"........"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
var px: u8 = 128
|
var px: u8 = 128
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
//!
|
//!
|
||||||
//! Run with `cargo run --bin gen_platformer_tiles`. The output is
|
//! Run with `cargo run --bin gen_platformer_tiles`. The output is
|
||||||
//! intended to be pasted into `platformer.ne` under the
|
//! intended to be pasted into `platformer.ne` under the
|
||||||
//! `sprite Tileset { chr: [...] }` and
|
//! `sprite Tileset { pixels: [...] }` and
|
||||||
//! `background Level { tiles: [...] }` blocks. Keeping the source of
|
//! `background Level { legend { ... } map: [...] palette_map: [...] }`
|
||||||
//! truth here (instead of hand-maintained hex in the `.ne` file)
|
//! blocks. Keeping the source of truth here (instead of
|
||||||
//! makes the tile art editable as ASCII and ensures the CHR bytes
|
//! hand-maintained ASCII in the `.ne` file) ensures the tile art,
|
||||||
//! and the nametable stay in sync with named tile indices.
|
//! the nametable, and the named tile indices all stay in sync.
|
||||||
//!
|
//!
|
||||||
//! Tiles are defined as 8×8 ASCII art where each character selects
|
//! Tiles are defined as 8×8 ASCII art where each character selects
|
||||||
//! one of 4 sub-palette slots:
|
//! one of 4 sub-palette slots:
|
||||||
|
|
@ -15,8 +15,10 @@
|
||||||
//! 'a' = colour 1
|
//! 'a' = colour 1
|
||||||
//! 'b' = colour 2
|
//! 'b' = colour 2
|
||||||
//! 'c' = colour 3
|
//! 'c' = colour 3
|
||||||
|
//!
|
||||||
use std::fmt::Write as _;
|
//! Both the generator *and* the `NEScript` parser accept `.abc` as
|
||||||
|
//! aliases for `.#%@`, so the output is ready to paste directly
|
||||||
|
//! into a `pixels:` block without translation.
|
||||||
|
|
||||||
/// A single 8×8 tile description: name + ASCII art.
|
/// A single 8×8 tile description: name + ASCII art.
|
||||||
struct Tile {
|
struct Tile {
|
||||||
|
|
@ -242,34 +244,32 @@ const BUSH: u8 = 13;
|
||||||
const QBLOCK: u8 = 14;
|
const QBLOCK: u8 = 14;
|
||||||
const SKY: u8 = 15;
|
const SKY: u8 = 15;
|
||||||
|
|
||||||
/// Encode one 8×8 tile into its 16-byte NES CHR representation
|
/// Validate that a tile's ASCII art is well-formed (8 rows × 8 cols,
|
||||||
/// (two 8-byte bitplanes: low bit then high bit).
|
/// only the legal `.abc` characters). Pixel→CHR encoding now happens
|
||||||
fn tile_to_chr(art: &str) -> [u8; 16] {
|
/// inside the `NEScript` parser when it sees the `pixels:` block, so
|
||||||
|
/// this generator's job is just to make sure the strings we paste
|
||||||
|
/// are syntactically valid.
|
||||||
|
fn validate_tile_art(name: &str, art: &str) {
|
||||||
let rows: Vec<&str> = art.lines().filter(|l| !l.is_empty()).collect();
|
let rows: Vec<&str> = art.lines().filter(|l| !l.is_empty()).collect();
|
||||||
assert_eq!(rows.len(), 8, "expected 8 rows, got {}", rows.len());
|
assert_eq!(
|
||||||
let mut chr = [0u8; 16];
|
rows.len(),
|
||||||
|
8,
|
||||||
|
"tile '{name}': expected 8 rows, got {}",
|
||||||
|
rows.len()
|
||||||
|
);
|
||||||
for (y, row) in rows.iter().enumerate() {
|
for (y, row) in rows.iter().enumerate() {
|
||||||
assert_eq!(row.len(), 8, "expected 8 cols in row {y}: {row:?}");
|
assert_eq!(
|
||||||
let (mut plane0, mut plane1) = (0u8, 0u8);
|
row.len(),
|
||||||
for (x, ch) in row.chars().enumerate() {
|
8,
|
||||||
let idx: u8 = match ch {
|
"tile '{name}' row {y}: expected 8 cols, got {row:?}"
|
||||||
'.' => 0,
|
);
|
||||||
'a' => 1,
|
for ch in row.chars() {
|
||||||
'b' => 2,
|
assert!(
|
||||||
'c' => 3,
|
matches!(ch, '.' | 'a' | 'b' | 'c'),
|
||||||
other => panic!("invalid tile char {other:?}"),
|
"tile '{name}' row {y}: invalid character '{ch}'"
|
||||||
};
|
);
|
||||||
if idx & 1 != 0 {
|
|
||||||
plane0 |= 0x80 >> x;
|
|
||||||
}
|
|
||||||
if idx & 2 != 0 {
|
|
||||||
plane1 |= 0x80 >> x;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
chr[y] = plane0;
|
|
||||||
chr[y + 8] = plane1;
|
|
||||||
}
|
}
|
||||||
chr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a 32×30 nametable for a static Mario-ish level vista.
|
/// Build a 32×30 nametable for a static Mario-ish level vista.
|
||||||
|
|
@ -367,62 +367,112 @@ fn build_attributes() -> [u8; 64] {
|
||||||
attr
|
attr
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_byte_block(bs: &[u8], per_row: usize, indent: &str) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
for chunk in bs.chunks(per_row) {
|
|
||||||
out.push_str(indent);
|
|
||||||
for (i, b) in chunk.iter().enumerate() {
|
|
||||||
if i > 0 {
|
|
||||||
out.push_str(", ");
|
|
||||||
}
|
|
||||||
let _ = write!(out, "{b}");
|
|
||||||
}
|
|
||||||
out.push_str(",\n");
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_hex_block(bs: &[u8], per_row: usize, indent: &str) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
for chunk in bs.chunks(per_row) {
|
|
||||||
out.push_str(indent);
|
|
||||||
for (i, b) in chunk.iter().enumerate() {
|
|
||||||
if i > 0 {
|
|
||||||
out.push_str(", ");
|
|
||||||
}
|
|
||||||
let _ = write!(out, "0x{b:02X}");
|
|
||||||
}
|
|
||||||
out.push_str(",\n");
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// ── CHR ──
|
// ── CHR tiles as pixel-art strings ──────────────────────────
|
||||||
let mut chr_all: Vec<u8> = Vec::new();
|
//
|
||||||
println!("// ── CHR tiles (paste into sprite Tileset {{ chr: [...] }}) ──");
|
// `NEScript`'s `sprite Name { pixels: [...] }` accepts one string
|
||||||
|
// per pixel row; the parser splits the grid into 8×8 tiles in
|
||||||
|
// row-major reading order, so emitting each 8-row tile
|
||||||
|
// sequentially (stacked vertically, 1 tile wide × 15 tiles tall)
|
||||||
|
// produces CHR tile indices 1..15 in the same order as the
|
||||||
|
// TILES array.
|
||||||
|
println!("// ── CHR tiles (paste into sprite Tileset {{ pixels: [...] }}) ──");
|
||||||
|
println!(" pixels: [");
|
||||||
for (i, tile) in TILES.iter().enumerate() {
|
for (i, tile) in TILES.iter().enumerate() {
|
||||||
|
validate_tile_art(tile.name, tile.art);
|
||||||
let tile_idx = i + 1;
|
let tile_idx = i + 1;
|
||||||
let chr = tile_to_chr(tile.art);
|
println!(" // tile {tile_idx}: {}", tile.name);
|
||||||
chr_all.extend_from_slice(&chr);
|
for row in tile.art.lines().filter(|l| !l.is_empty()) {
|
||||||
println!(" // tile {tile_idx}: {}", tile.name);
|
println!(" \"{row}\",");
|
||||||
print!("{}", emit_hex_block(&chr, 16, " "));
|
}
|
||||||
}
|
}
|
||||||
println!(
|
println!(" ]\n");
|
||||||
"// total tiles: {}, total CHR bytes: {}\n",
|
|
||||||
TILES.len(),
|
|
||||||
chr_all.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Nametable ──
|
// ── Background tilemap via legend + map form ────────────────
|
||||||
|
//
|
||||||
|
// Each distinct nametable tile gets one easy-to-read legend
|
||||||
|
// character. `.` is the most common (sky), so use it for the
|
||||||
|
// default fill to keep the `map:` strings tidy. The compiler
|
||||||
|
// validates every character — any typo in the rows below is
|
||||||
|
// caught by the parser rather than by a render bug at runtime.
|
||||||
|
println!("// ── Nametable (paste into background Level {{ legend/map/palette_map }}) ──");
|
||||||
|
println!(" legend {{");
|
||||||
|
println!(" \".\": 15 // sky (blank)");
|
||||||
|
println!(" \"<\": 10 // cloud left half");
|
||||||
|
println!(" \">\": 11 // cloud right half");
|
||||||
|
println!(" \"#\": 9 // brick");
|
||||||
|
println!(" \"Q\": 14 // question block");
|
||||||
|
println!(" \"^\": 12 // hill silhouette");
|
||||||
|
println!(" \"*\": 13 // bush");
|
||||||
|
println!(" \"=\": 7 // grass top");
|
||||||
|
println!(" \"%\": 8 // dirt");
|
||||||
|
println!(" }}");
|
||||||
|
println!();
|
||||||
|
println!(" map: [");
|
||||||
let nt = build_nametable();
|
let nt = build_nametable();
|
||||||
assert_eq!(nt.len(), 960);
|
assert_eq!(nt.len(), 960);
|
||||||
println!("// ── Nametable (paste into background Level {{ tiles: [...] }}) ──");
|
let legend = legend_char_for;
|
||||||
print!("{}", emit_byte_block(&nt, 16, " "));
|
for row in 0..30 {
|
||||||
|
let mut s = String::from(" \"");
|
||||||
|
for col in 0..32 {
|
||||||
|
s.push(legend(nt[row * 32 + col]));
|
||||||
|
}
|
||||||
|
s.push_str("\",");
|
||||||
|
println!("{s}");
|
||||||
|
}
|
||||||
|
println!(" ]\n");
|
||||||
|
|
||||||
// ── Attributes ──
|
// ── Palette map (auto-packs the 64-byte attribute table) ────
|
||||||
|
//
|
||||||
|
// `palette_map:` is a 16-wide grid of sub-palette digits (one
|
||||||
|
// per 16×16 metatile). The parser packs pairs of rows into the
|
||||||
|
// 8×8 attribute table automatically, so we emit the metatile
|
||||||
|
// grid directly and skip the awkward `(br<<6)|(bl<<4)|(tr<<2)|tl`
|
||||||
|
// bit-packing the old raw-bytes form demanded.
|
||||||
|
//
|
||||||
|
// The attribute table covers 16 metatile rows (8 attr rows ×
|
||||||
|
// 2 each); the last row sits below the visible 240-scanline
|
||||||
|
// screen but the PPU still reads it, so we emit all 16 rows to
|
||||||
|
// match whatever the original hand-packed attribute byte was.
|
||||||
|
println!("// ── Attributes (paste into background Level {{ palette_map: [...] }}) ──");
|
||||||
|
println!(" palette_map: [");
|
||||||
let attr = build_attributes();
|
let attr = build_attributes();
|
||||||
assert_eq!(attr.len(), 64);
|
for my in 0..16 {
|
||||||
println!("\n// ── Attributes (paste into background Level {{ attributes: [...] }}) ──");
|
let mut s = String::from(" \"");
|
||||||
print!("{}", emit_byte_block(&attr, 16, " "));
|
for mx in 0..16 {
|
||||||
|
// Recover the sub-palette index for metatile (mx, my)
|
||||||
|
// from the packed attribute table. Each byte covers a
|
||||||
|
// 2×2 metatile block at attr[(my/2)*8 + mx/2], with
|
||||||
|
// quadrants laid out as BR BL TR TL in the high bits.
|
||||||
|
let byte = attr[(my / 2) * 8 + mx / 2];
|
||||||
|
let quadrant = match (mx % 2, my % 2) {
|
||||||
|
(0, 0) => byte & 0b11,
|
||||||
|
(1, 0) => (byte >> 2) & 0b11,
|
||||||
|
(0, 1) => (byte >> 4) & 0b11,
|
||||||
|
_ => (byte >> 6) & 0b11,
|
||||||
|
};
|
||||||
|
s.push(char::from(b'0' + quadrant));
|
||||||
|
}
|
||||||
|
s.push_str("\",");
|
||||||
|
println!("{s}");
|
||||||
|
}
|
||||||
|
println!(" ]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a CHR tile index back to its legend character in the
|
||||||
|
/// generated `map:` block. Must stay in sync with the `legend { ... }`
|
||||||
|
/// block printed by `main()`.
|
||||||
|
fn legend_char_for(tile: u8) -> char {
|
||||||
|
match tile {
|
||||||
|
15 => '.',
|
||||||
|
10 => '<',
|
||||||
|
11 => '>',
|
||||||
|
9 => '#',
|
||||||
|
14 => 'Q',
|
||||||
|
12 => '^',
|
||||||
|
13 => '*',
|
||||||
|
7 => '=',
|
||||||
|
8 => '%',
|
||||||
|
other => panic!("no legend char for tile {other}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -822,18 +822,24 @@ impl Parser {
|
||||||
for (ry, row) in rows.iter().enumerate() {
|
for (ry, row) in rows.iter().enumerate() {
|
||||||
let mut line = Vec::with_capacity(width);
|
let mut line = Vec::with_capacity(width);
|
||||||
for (rx, ch) in row.chars().enumerate() {
|
for (rx, ch) in row.chars().enumerate() {
|
||||||
|
// Three vocabularies map to the same 0-3 index so
|
||||||
|
// artists can use whichever feels natural:
|
||||||
|
// `. # % @` — shade-intensity glyphs (dense = hi)
|
||||||
|
// `0 1 2 3` — literal palette-index digits
|
||||||
|
// `. a b c` — letter form used by most NES tools
|
||||||
let val = match ch {
|
let val = match ch {
|
||||||
'.' | ' ' | '0' => 0u8,
|
'.' | ' ' | '0' => 0u8,
|
||||||
'#' | '1' => 1,
|
'#' | '1' | 'a' | 'A' => 1,
|
||||||
'%' | '2' => 2,
|
'%' | '2' | 'b' | 'B' => 2,
|
||||||
'@' | '3' => 3,
|
'@' | '3' | 'c' | 'C' => 3,
|
||||||
other => {
|
other => {
|
||||||
return Err(Diagnostic::error(
|
return Err(Diagnostic::error(
|
||||||
ErrorCode::E0201,
|
ErrorCode::E0201,
|
||||||
format!(
|
format!(
|
||||||
"sprite '{sprite_name}' pixel at ({rx}, {ry}) has \
|
"sprite '{sprite_name}' pixel at ({rx}, {ry}) has \
|
||||||
invalid character '{other}'; use '.'/' '/'0' for \
|
invalid character '{other}'; use '.'/' '/'0' for \
|
||||||
index 0, '#'/'1' for 1, '%'/'2' for 2, '@'/'3' for 3"
|
index 0, '#'/'1'/'a' for 1, '%'/'2'/'b' for 2, \
|
||||||
|
'@'/'3'/'c' for 3"
|
||||||
),
|
),
|
||||||
key_span,
|
key_span,
|
||||||
));
|
));
|
||||||
|
|
@ -2947,18 +2953,21 @@ fn tilemap_to_bytes(
|
||||||
/// The attribute layout is notoriously awkward: each attribute byte
|
/// The attribute layout is notoriously awkward: each attribute byte
|
||||||
/// covers a 32×32-pixel region (four 16×16 metatiles) packed as
|
/// covers a 32×32-pixel region (four 16×16 metatiles) packed as
|
||||||
/// `BR BL TR TL` — top-left in the low bits, bottom-right in the high
|
/// `BR BL TR TL` — top-left in the low bits, bottom-right in the high
|
||||||
/// bits. A 32×30-pixel nametable has a 16×15 metatile grid but the
|
/// bits. The attribute table is a fixed 8×8 = 64 bytes covering 16
|
||||||
/// attribute table is a fixed 8×8 = 64 bytes, so the last half-row
|
/// metatile rows, even though only the top 15 (the visible 240
|
||||||
/// of metatiles (rows 14-15, which render below the visible area) is
|
/// scanlines) render on screen. Programs may declare up to 16 rows
|
||||||
/// always zero-padded.
|
/// so the off-screen half picks up sensible attribute bytes; if
|
||||||
|
/// exactly 15 are given, the parser auto-replicates row 14 down
|
||||||
|
/// into row 15 so the last attribute byte stays consistent with
|
||||||
|
/// what's visible.
|
||||||
fn palette_map_to_attrs(bg_name: &str, rows: &[String], span: Span) -> Result<Vec<u8>, Diagnostic> {
|
fn palette_map_to_attrs(bg_name: &str, rows: &[String], span: Span) -> Result<Vec<u8>, Diagnostic> {
|
||||||
// 16 metatile cols × up to 15 metatile rows.
|
if rows.len() > 16 {
|
||||||
if rows.len() > 15 {
|
|
||||||
return Err(Diagnostic::error(
|
return Err(Diagnostic::error(
|
||||||
ErrorCode::E0201,
|
ErrorCode::E0201,
|
||||||
format!(
|
format!(
|
||||||
"background '{bg_name}' palette_map has {} rows; maximum is 15 \
|
"background '{bg_name}' palette_map has {} rows; maximum is 16 \
|
||||||
(one entry per 16×16 metatile row)",
|
(15 visible metatile rows + 1 off-screen row for the bottom \
|
||||||
|
half of the last attribute byte)",
|
||||||
rows.len()
|
rows.len()
|
||||||
),
|
),
|
||||||
span,
|
span,
|
||||||
|
|
@ -3002,6 +3011,14 @@ fn palette_map_to_attrs(bg_name: &str, rows: &[String], span: Span) -> Result<Ve
|
||||||
grid[ry][rx] = idx;
|
grid[ry][rx] = idx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If the user gave exactly 15 rows, replicate row 14 into row 15
|
||||||
|
// so the last attribute byte's bottom-half picks up the same
|
||||||
|
// sub-palette as the visible bottom of the screen. Users who
|
||||||
|
// want explicit control over the off-screen row can supply all
|
||||||
|
// 16 rows.
|
||||||
|
if rows.len() == 15 {
|
||||||
|
grid[15] = grid[14];
|
||||||
|
}
|
||||||
// Pack into the 8×8 attribute table. Each attribute byte covers
|
// Pack into the 8×8 attribute table. Each attribute byte covers
|
||||||
// a 2×2 block of metatiles:
|
// a 2×2 block of metatiles:
|
||||||
// bits 0-1 = top-left (grid[ay*2 ][ax*2 ])
|
// bits 0-1 = top-left (grid[ay*2 ][ax*2 ])
|
||||||
|
|
|
||||||
|
|
@ -1780,6 +1780,54 @@ fn parse_sprite_pixel_art_rejects_non_multiple_of_8() {
|
||||||
assert!(diags.contains(&crate::errors::ErrorCode::E0201));
|
assert!(diags.contains(&crate::errors::ErrorCode::E0201));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_sprite_pixel_art_accepts_abc_alias() {
|
||||||
|
// `.abc` is the vocabulary every NES tool uses for 2-bit pixel
|
||||||
|
// art; the parser should accept it interchangeably with `.#%@`
|
||||||
|
// and `.0123`. Two sprites written in the two forms must
|
||||||
|
// encode to bit-identical CHR bytes.
|
||||||
|
let letters = r#"
|
||||||
|
game "T" { mapper: NROM }
|
||||||
|
sprite A {
|
||||||
|
pixels: [
|
||||||
|
"........",
|
||||||
|
"...aa...",
|
||||||
|
"..abba..",
|
||||||
|
".abbccb.",
|
||||||
|
".abbccb.",
|
||||||
|
"..abba..",
|
||||||
|
"...aa...",
|
||||||
|
"........"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
on frame { wait_frame }
|
||||||
|
start Main
|
||||||
|
"#;
|
||||||
|
let glyphs = r#"
|
||||||
|
game "T" { mapper: NROM }
|
||||||
|
sprite A {
|
||||||
|
pixels: [
|
||||||
|
"........",
|
||||||
|
"...##...",
|
||||||
|
"..#%%#..",
|
||||||
|
".#%%@@%.",
|
||||||
|
".#%%@@%.",
|
||||||
|
"..#%%#..",
|
||||||
|
"...##...",
|
||||||
|
"........"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
on frame { wait_frame }
|
||||||
|
start Main
|
||||||
|
"#;
|
||||||
|
let p1 = parse_ok(letters);
|
||||||
|
let p2 = parse_ok(glyphs);
|
||||||
|
match (&p1.sprites[0].chr_source, &p2.sprites[0].chr_source) {
|
||||||
|
(AssetSource::Inline(a), AssetSource::Inline(b)) => assert_eq!(a, b),
|
||||||
|
_ => panic!("expected inline CHR on both sprites"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_sprite_pixel_art_rejects_ragged_rows() {
|
fn parse_sprite_pixel_art_rejects_ragged_rows() {
|
||||||
let src = r#"
|
let src = r#"
|
||||||
|
|
@ -2083,6 +2131,110 @@ fn parse_background_palette_map_packs_attributes() {
|
||||||
assert_eq!(attrs[1], 0x55);
|
assert_eq!(attrs[1], 0x55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_background_palette_map_15_rows_replicates_off_screen_row() {
|
||||||
|
// When a program only declares 15 rows (the visible metatile
|
||||||
|
// grid), the parser should replicate the bottom row into the
|
||||||
|
// off-screen 16th row so the last attribute byte's bottom half
|
||||||
|
// picks up the same sub-palette as the visible bottom.
|
||||||
|
let src = r#"
|
||||||
|
game "T" { mapper: NROM }
|
||||||
|
background Stage {
|
||||||
|
legend { ".": 0 }
|
||||||
|
map: ["................................"]
|
||||||
|
palette_map: [
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222",
|
||||||
|
"2222222222222222"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
on frame { wait_frame }
|
||||||
|
start Main
|
||||||
|
"#;
|
||||||
|
let prog = parse_ok(src);
|
||||||
|
let attrs = &prog.backgrounds[0].attributes;
|
||||||
|
// Every byte covers 4 metatiles at sub-palette 2, so each
|
||||||
|
// byte should be 0b10_10_10_10 = 0xAA. Crucially, the last
|
||||||
|
// attribute row (bytes 56-63) must also be 0xAA — if the
|
||||||
|
// auto-replicate didn't happen, bytes 56-63 would be 0x0A
|
||||||
|
// (top-half 2, bottom-half 0) since the 16th metatile row
|
||||||
|
// would default to sub-palette 0.
|
||||||
|
for b in attrs {
|
||||||
|
assert_eq!(*b, 0xAA, "every attribute byte should be 2s");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_background_palette_map_16_rows_accepted() {
|
||||||
|
// The parser accepts an explicit 16th row for programs that
|
||||||
|
// want full control over the off-screen attribute byte.
|
||||||
|
let src = r#"
|
||||||
|
game "T" { mapper: NROM }
|
||||||
|
background Stage {
|
||||||
|
legend { ".": 0 }
|
||||||
|
map: ["................................"]
|
||||||
|
palette_map: [
|
||||||
|
"1111111111111111", "1111111111111111",
|
||||||
|
"1111111111111111", "1111111111111111",
|
||||||
|
"1111111111111111", "1111111111111111",
|
||||||
|
"1111111111111111", "1111111111111111",
|
||||||
|
"1111111111111111", "1111111111111111",
|
||||||
|
"1111111111111111", "1111111111111111",
|
||||||
|
"1111111111111111", "1111111111111111",
|
||||||
|
"1111111111111111",
|
||||||
|
"2222222222222222"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
on frame { wait_frame }
|
||||||
|
start Main
|
||||||
|
"#;
|
||||||
|
let prog = parse_ok(src);
|
||||||
|
let attrs = &prog.backgrounds[0].attributes;
|
||||||
|
// The last attribute byte's top half covers metatile row 14
|
||||||
|
// (sub-palette 1) and bottom half covers metatile row 15
|
||||||
|
// (sub-palette 2). Byte = (1) | (1 << 2) | (2 << 4) | (2 << 6)
|
||||||
|
// = 0x01 | 0x04 | 0x20 | 0x80 = 0xA5.
|
||||||
|
assert_eq!(attrs[63], 0xA5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_background_palette_map_rejects_17_rows() {
|
||||||
|
let src = r#"
|
||||||
|
game "T" { mapper: NROM }
|
||||||
|
background Bad {
|
||||||
|
legend { ".": 0 }
|
||||||
|
map: ["."]
|
||||||
|
palette_map: [
|
||||||
|
"0000000000000000", "0000000000000000",
|
||||||
|
"0000000000000000", "0000000000000000",
|
||||||
|
"0000000000000000", "0000000000000000",
|
||||||
|
"0000000000000000", "0000000000000000",
|
||||||
|
"0000000000000000", "0000000000000000",
|
||||||
|
"0000000000000000", "0000000000000000",
|
||||||
|
"0000000000000000", "0000000000000000",
|
||||||
|
"0000000000000000", "0000000000000000",
|
||||||
|
"0000000000000000"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
on frame { wait_frame }
|
||||||
|
start Main
|
||||||
|
"#;
|
||||||
|
let diags = parse_err(src);
|
||||||
|
assert!(diags.contains(&crate::errors::ErrorCode::E0201));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_background_raw_tiles_and_attributes_still_work() {
|
fn parse_background_raw_tiles_and_attributes_still_work() {
|
||||||
// Backward compat: legacy inline byte arrays should keep parsing
|
// Backward compat: legacy inline byte arrays should keep parsing
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue