1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 00:45:38 +00:00
nescript/docs/future-work.md
Claude c5b04c2065
platformer: move HUD from sprite OAM slots to background + sprite-0 split
The status bar now paints into NT row 1 (coin + score digits on
the left, heart + lives digit on the right) using the `bg3`
sub-palette that matches `sp0` pixel-for-pixel. A single OAM
slot-0 anchor sprite sits over the coin tile; its one opaque
pixel lines up with the coin's bottom row so sprite-0 hit fires
at scanline 15, and a trailing `sprite_0_split(camera_x, 0)`
latches the playfield scroll starting at scanline 16. NT rows
0-1 stay pinned while scanlines 16+ scroll with the camera.

Score / lives updates are shadow-compared (`last_score`,
`last_lives`) so the VRAM ring sees an entry only when the
backing state actually changes — most frames append zero bytes.
OAM footprint drops from 5 sprites per frame down to 1.

Tile pipeline gains a 27th entry — a 7-transparent-row + 1-pixel
anchor — so the sprite-0 hit lands on scanline 15 instead of
scanline 8 (the latter would smear the HUD glyphs across the
split). `gen_platformer_tiles.rs` is updated in lockstep.

Ancillary changes: `bg3` retuned from `[yellow, orange,
dk_orange]` to `[red, orange, white]` (matching `sp0`);
`palette_map` row 0 flips from bg0 to bg3; legend gains `o`, `h`,
`0`, `3` so the initial map can preload the static HUD tiles and
the committed nametable already reads "coin 00 ... heart 3" on
frame 0.

`docs/future-work.md` loses the sprite-0 HUD follow-up section
(this commit lands it). Goldens + gif refreshed.
2026-04-20 17:21:20 +00:00

650 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Future Work
This document tracks the gaps between what NEScript currently compiles and
what the spec describes. Items are grouped by area. Anything implemented and
tested is omitted — `git log` is the authoritative record of what shipped.
---
## PNG-sourced assets
**What ships today.** `palette Name { colors: [...] }` and
`background Name { tiles: [...], attributes: [...] }` declarations with
inline byte arrays, plus `palette Name @palette("file.png")` and
`background Name @nametable("file.png")` for PNG-sourced variants.
The palette path maps each pixel to its nearest NES master-palette index
(via `nearest_nes_color()` in `src/assets/palette.rs`), deduplicates, and
emits the 32-byte blob; the nametable path slices a 256×240 PNG into the
32×30 tile grid, deduplicates (max 256 unique tiles), and emits the 960+64
byte nametable/attribute blobs. The nametable path now **also auto-generates
the per-tile CHR data** via `png_to_nametable_with_chr` and slots it into
CHR ROM after the user's sprite tile range — see
`examples/auto_chr_background.ne` for the end-to-end flow.
`--memory-map` reports per-blob PRG ROM addresses and a running total
alongside the variable layout.
**Still TODO.**
- **Per-state background rendering control** — programs currently load a
single nametable at reset. Per-state swaps work but are limited by the
NMI-time write budget (~2273 cycles, enough for a palette but not a
full 1024-byte nametable).
- **Per-quadrant palette selection from PNG sources** — the
`png_to_nametable_with_chr` attribute path picks sub-palettes based on
brightness buckets, which is fine for grayscale demos but doesn't let
the user say "this 32×32 tile uses sub-palette 2". A separate
`palette_map:` shortcut exists for inline backgrounds; the PNG path
could grow a sibling `@palette_map("hint.png")` that overrides the
brightness buckets.
---
## User code distribution across switchable banks
**What ships today.** `bank Foo { fun bar() { ... } }` nesting places user
functions into a specific switchable bank. The codegen emits per-bank
instruction streams; the linker runs a two-pass assembly (discover labels
per-bank, then resolve with the merged label table) so banked code can
still reference fixed-bank symbols. Cross-bank calls — both fixed → banked
*and* banked → banked — are rewritten to `JSR __tramp_<name>`, where each
trampoline is a per-function stub in the fixed bank that reads the
caller's current bank from `ZP_BANK_CURRENT`, pushes it on the hardware
stack, switches to the target, JSRs the entry, then pulls and restores
the caller's bank. `gen_mapper_init` seeds `ZP_BANK_CURRENT` with the
fixed bank index at reset so the first cross-bank call from the fixed
bank still leaves the fixed bank mapped at $8000. See
`examples/uxrom_user_banked.ne` (fixed → banked) and
`examples/uxrom_banked_to_banked.ne` (banked → banked).
**Still TODO.**
- **Greedy size-packing.** Placement is explicit-only today — there is no
pass that takes a program with too much fixed-bank code and
automatically spills the biggest leaf functions to declared empty banks.
- **MMC3 per-state-handler split** — the `mmc3_per_state_split.ne`
example still uses the legacy fixed-bank placement for its handlers.
Extending the banked-fun syntax to state handlers (plus trampoline
emission on handler dispatch) would unify the two paths. The blocker
isn't the trampoline — those work for any caller now — but the
state-handler dispatcher in the IR codegen needs to learn that
state handlers can live in a switchable bank, and to JSR through a
trampoline whose entry is the handler label.
---
## Language feature gaps (post-v0.1)
From the spec's "Reserved for Future Versions" section:
| Feature | Description |
|--------------------|-----------------------------------------------------------------------|
| **Fixed-point** | `fixed8.8` type for sub-pixel movement with operator support. |
| **Text / HUD** | Font sheet declarations + layout system for scores, health, menus. |
| **Tilemaps** | Declarative level data with built-in collision queries. |
| **SRAM / saves** | Persistent storage declarations for battery-backed save data. |
NES 2.0 headers are now supported via `game Foo { header: nes2 }` — see
`src/rom/mod.rs`.
**Metasprites** are now supported via `metasprite Name { sprite: ...,
dx: [...], dy: [...], frame: [...] }` — see `examples/metasprite_demo.ne`.
The IR lowering expands `draw Hero at: (x, y)` into one `DrawSprite` op
per tile, with each tile's frame index offset by the underlying sprite's
base tile so the codegen sees a stream of regular draws and the OAM
cursor allocator picks them up unchanged. Negative offsets and
runtime-varying tile selection are still TODO — the current form takes
literal `u8` offsets.
### Struct / array field widths
Nested struct fields (`hero.pos.x`) and array struct fields
(`hero.inv[i]`) now compile end-to-end. The analyzer recursively
flattens the struct layout into per-leaf synthetic variables (with
intermediate `Struct(...)` symbols for the dotted prefixes), and the
parser loops the dotted chain in `parse_primary` and
`parse_assign_or_call` so the existing `format!("{name}.{field}")`
synthetic-name model still works without IR changes. Array-of-structs
is still rejected with E0201 — the synthetic-variable model can't
index per-element struct layouts without further codegen work, see
`src/analyzer/mod.rs::register_struct`.
---
## Audio pipeline
**What ships today.** Frame-walking pulse driver with `sfx Name { duty, pitch,
volume }` and `music Name { duty, volume, repeat, notes }` blocks; builtin
effects and tracks; a 60-entry period table; `__audio_used` marker that
elides the whole subsystem when no program statement references it. **Plus**
`channel: triangle` and `channel: noise` on `sfx` blocks, which splice in
per-channel slots that write to $4008-$400B (triangle) or $400C-$400F
(noise) when a program declares them. **Plus per-frame pitch envelopes
on Pulse-1 sfx** — a `pitch:` array with more than one distinct value
opts into a separate `__sfx_pitch_<name>` blob that the audio tick walks
in lockstep with the volume envelope, writing `$4002` on every NMI for a
real frequency-sweeping pulse channel. Pulse-only programs without
varying-pitch sfx still produce byte-identical driver code. See
`examples/noise_triangle_sfx.ne` and `examples/sfx_pitch_envelope.ne`.
**Still TODO for richer audio.**
- **DMC channel** — delta-modulation sample playback is not wired yet.
- **Multi-channel tracker playback** — one `notes` list per channel on
`music` blocks (the triangle/noise SFX are one-shot envelopes, not a
tracker).
- **`@sfx("file.nsf")` / `@music("file.ftm")`** — neither the NSF nor the
FamiTracker format is parsed yet.
- **Per-frame pitch envelopes on triangle / noise sfx** — the data
shape (a parallel pitch array on the `sfx` block) is the same as for
Pulse-1, but the runtime triangle/noise tick blocks currently only
write their volume registers (`$4008` / `$400C`). Extending them to
also walk a per-channel pitch envelope and write `$400A` / `$400E`
is the natural next step now that the pulse path is proven.
---
## Debug instrumentation
**What ships today.** `debug.log(...)` and `debug.assert(...)` lower to $4800
writes when `--debug` is passed, and are stripped entirely in release builds.
`--symbols <path>` writes a Mesen-compatible `.mlb` file listing function,
state-handler, and variable addresses (with PRG ROM offsets for code and
CPU addresses for RAM). `--source-map <path>` consumes the `SourceLoc` IR
op and writes a plain-text map of `<rom_offset> <file_id> <line> <col>`
entries for every lowered statement. **`--dbg <path>` writes a
ca65-compatible `.dbg` debug-info file** that Mesen / Mesen2 / fceuX pick
up automatically for source-level stepping, labelled variable inspection,
and symbol-based breakpoints. The file stitches together the linker's
label table, the `__src_<N>` IR markers, and the analyzer's variable
allocations into the `file`/`mod`/`seg`/`scope`/`span`/`line`/`sym`
records documented at
<https://cc65.github.io/doc/debugfile.html>. `ooffs` on the segment
record tracks the fixed bank's PRG-relative start, so banked ROMs
(MMC1/UxROM/MMC3) also map cleanly inside the debugger. Debug builds emit array bounds checks
(CMP against size, BCC past a `JMP __debug_halt` wedge) and bump an
overrun counter at `$07FF` in the NMI handler when the main loop didn't
reach `wait_frame` before the next vblank.
**Plus four query expressions** that mirror the counter/sticky pattern:
`debug.frame_overrun_count()` / `debug.frame_overran()` return the cumulative
overrun counter and a per-frame sticky bit so user code can write
`debug.assert(not debug.frame_overran())` guards, and
`debug.sprite_overflow_count()` / `debug.sprite_overflow()` do the same for
the NES PPU's sprite-per-scanline flag (`$2002` bit 5), which the NMI
handler samples once per frame in debug mode. All four sticky bits clear
on the next `wait_frame`.
**Still TODO.**
- **`debug.overlay(x, y, text)`** — needs the text/HUD subsystem (see
Language feature gaps).
---
## Code quality / tooling
### Register allocator
IR temps still spill to a recycled zero-page slot (`$80-$FF`). Most of
the obvious waste is now handled:
- The `remove_dead_loads` peephole pass steps past opcodes that touch
neither A nor the flags A cares about — INC/DEC/STX/STY/LDX/LDY in
memory mode, INX/INY/DEX/DEY, and the flag ops
(CLC/SEC/CLI/SEI/CLD/SED/CLV). That lets a later `LDA` overwrite
kill a redundant earlier `LDA` even when an index-register load or
counter bump sits between them. The canonical
`LDA #imm / LDY oam_cursor / LDA #imm / STA $0200,Y` shape emitted
by every single-tile `draw` collapses to just
`LDY oam_cursor / LDA #imm / STA $0200,Y`, saving 2 bytes per draw.
Across the committed examples this shaved 4-9 % of the LDA count
in the larger programs (platformer, war, pong).
- Copy propagation + dead-store elimination in the same pass handle
the `STA $80 / … / LDA $80` spill/reload pattern whenever the
source and destination aren't separated by a JSR or a label.
**Constraint worth recording.** Any cycle-saving optimization shifts
audio timing: the SFX/music driver reads a frame counter on every NMI,
and shortening the main-loop pass between two `wait_frame` calls
changes how many poll iterations complete before the next NMI fires.
The emulator harness captures a sample-exact audio hash at frame 180,
so any such change flips the audio goldens for every audio-emitting
example (audio_demo, friendly_assets, noise_triangle_sfx, platformer,
pong, war). The visual PNG goldens stay stable — PPU writes still
land in the same vblank — but the audio churn is real and has to be
accepted on every pass that touches the codegen. The initial LDX/LDY
extension on this branch went through that re-baseline once; future
passes will too.
**Remaining wins to chase** if someone wants to push density further:
- **Cross-block A-tracking.** `remove_redundant_loads` clears its
A-equivalence tracker on LDX/LDY because LDX/LDY clobber the N/Z
flags that an immediately-following branch might rely on. Splitting
the tracker into "value still valid" vs "flags still valid" lets it
keep the value-side equivalence across LDX/LDY for downstream STA
rewrites.
- **CFG-aware allocation into X / Y.** The codegen never picks X or Y
as a temp slot today; they're used only in the specific shapes the
existing IR ops emit. A pass that takes a temp with ≤2 uses, lives
in a straight-line block, and picks X or Y would remove both the
STA and the matching LDA.
- **Skipping the spill for temps consumed by the very next op.** The
IR codegen always emits `STA slot` after producing a temp; when the
next op's first source is that temp, the value could stay in A and
the consumer skips its `LDA`. The peephole's `remove_sta_then_lda`
+ `remove_dead_temp_stores` already pick up most cases post-hoc,
but producing tighter code at codegen avoids the overhead and
handles cases where an intervening label blocks the peephole.
### State-local memory overlay follow-ups
State-local variables are now overlaid across mutually-exclusive states
(see the analyzer's per-state allocation cursor rewind and the IR
lowerer's `on_enter` initializer prologue), but a few pieces are still
missing:
- **Same-named locals across different states.** `register_var` stores
state-locals under their bare name, so two states each declaring
`var timer: u8` collide with E0501. A per-state symbol-table scope
prefix would let each state carve its own namespace while keeping
the overlay.
- **Struct-literal and array-literal initializers on state-locals.**
The on-enter prologue lowers scalar initializers cleanly, and
struct-literal initializers fall back to per-field stores, but
array-literal initializers (`var xs: u8[4] = [1,2,3,4]`) are
skipped. A runtime `memcpy` from a ROM blob into the overlay
slot (mirroring the reset-time global path) is the natural
lowering.
- **Handler-local overlay.** Handler-local `var`s declared inside
`on_frame { ... }` are already per-handler scoped via
`current_scope_prefix`, but they get a dedicated RAM slot for the
program's lifetime. Overlaying them inside each handler's stack
frame — using a per-handler bump allocator that resets on each
call — would shave a few bytes more on programs with many deep
handlers.
### Cross-block temp live-range analysis
The slot recycler is function-local per-block. Temps that flow across block
boundaries get a dedicated slot for the entire function, even if a later
block could reuse the slot.
### WASM build target
To build a browser IDE we would need to route file I/O through a trait so the
core pipeline works on `&str → Vec<u8>` without touching `std::fs`. Today the
parser's preprocess pass and the asset resolver read files directly.
---
## Error message polish
### Unused error codes
`ErrorCode` only defines codes that are actually emitted. Previously there
were placeholder variants (`E0202` invalid cast, `E0403` unreachable state)
marked `#[allow(dead_code)]`; those were removed during cleanup. If those
semantics come back, add the codes at that point.
---
## cc65/nesdoug parity gaps
The nesdoug tutorial series + neslib expose a broad surface that
NEScript can't currently express. This section enumerates the gaps
in the order they should probably be tackled (cheapest/highest-
leverage first). Anything we finish moves out of this section and
into the top of the file with a "ships today" note.
### A. Numeric types beyond `u8 / i8 / u16 / i16 / bool`
`i16` ships today; see `examples/i16_demo.ne` and `examples/signed_compare.ne`.
Signed ordering comparisons and narrow-to-wide sign-extension both ship
too — `Cmp{Lt,Gt,LtEq,GtEq}` and their 16-bit siblings carry a
`Signedness` field that drives the canonical `CMP / SBC / BVC / EOR #$80`
overflow-correction idiom in `gen_cmp_signed_set_n`, and `widen()` emits
`IrOp::SignExtend` for signed narrow values instead of the zero-extended
`LoadImm 0` it used to. Casts to `u8`/`u16` strip the signed flag so
explicit `as` opt-outs stay unsigned.
Lower-priority numeric follow-up:
- **`u32` / `i32`.** Realistically needed only for score totals and
frame counters. A synthesizable pair of 16-bit halves is usually
enough.
### B. Pointers & function pointers
NEScript has no pointer type. This blocks indirect-dispatch
tables (`jmp (vec,x)`), variable-size buffer manipulation, and
passing "which thing" to a helper. cc65's `__fastcall__` function
pointers via wrapped-call + bank IDs are load-bearing for every
game over 32 KB. Design sketch:
- Introduce `*T` / `fn(T) -> U` type grammar.
- Spell a new IR op `CallIndirect` that takes an address in a
16-bit temp, plus a `BankHint` so cross-bank pointers trampoline
automatically.
- For fixed-bank-only code we can lower to a raw `JSR ($vec)`
equivalent (`JMP ($vec)` + return stub).
### C. Bitfields and unions
OAM attribute bytes, controller masks, collision-flag words, and
MMC3 register bits all want bitfield syntax (`struct OamAttr { pal:
u2, priority: u1, flip_h: u1, flip_v: u1 }`). Unions show up less
often but are useful for reading/writing the same bytes as two
different shapes (e.g. a 16-bit counter viewed as `lo: u8, hi: u8`).
### D. Full inline-assembly escape hatch
`.label:` ships today — the codegen mangles dot-labels into per-block
unique names (`__ilab_<N>_label`) so two inline-asm blocks in the
same function can both use `.loop:` without colliding. See
`tests/integration_test.rs::inline_asm_dot_labels_are_per_block_unique`.
Still TODO:
- Accept cc65's `asm()` format specifiers — `%b` (byte), `%w`
(word), `%l` (long), `%v` (var), `%o` (offset), `%g` (global),
`%s` (string) — so users can splat a compiler-allocated symbol
into a hot-loop fragment.
- Extend the directive allow-list: `.byte`, `.word`, `.res`,
`.repeat / .endrep`, `.macro / .endmacro`. The assembler can
already encode these.
### E. Dense-`match` jump tables
`match u8 { 0 => ..., 1 => ..., ... }` desugars to an if/else
chain at parse time today, which is `O(n)` compares. For dense
(<= 256 entries with <= 4× spread) integer matches, lower to:
```
ASL A ; index *= 2
TAX
LDA table_lo,X
STA $00
LDA table_hi,X
STA $01
JMP ($0000)
```
…with a per-branch `.word` table emitted in the function prologue.
This matters most for state dispatch and attack/weapon tables.
### F. Recursion stance (design constraint, not a bug)
The analyzer rejects recursive calls with E0402. That's the right
call for a compiler targeting a 6502 hardware stack, but it's not
documented as a **design choice** anywhere. Add a paragraph to the
language guide explaining why, plus a pointer to the hand-rolled
explicit-stack pattern (small `u8[N]` stack + `u8` top).
### G. VRAM update buffer follow-ups
`nt_set(x, y, tile)`, `nt_attr(x, y, value)`, and
`nt_fill_h(x, y, len, tile)` ship today — see
`examples/vram_buffer_demo.ne` (minimal drain test) and
`examples/hud_demo.ne` (realistic score/lives HUD with
shadow-compare writes and one-shot attribute paint). The runtime
ring lives at `$0400-$04FF` (gated on the `__vram_buf_used`
marker; the analyzer bumps the user-RAM bump pointer to `$0500`
when the buffer is in use). Each append lays down
`[len][addr_hi][addr_lo][data…]` and writes a fresh `0` sentinel;
the NMI drains the buffer at vblank via `LDA $0400,X / STA
$2007` indexed-absolute (4 cycles per data byte, no ZP cost),
then resets `$2006`/`$2005` to zero so the PPU's scroll latch is
clean for the next frame (otherwise trailing `$2006` writes shift
the rendered image by however many cells we wrote past `$2000`).
Still TODO:
- **Vertical (column) writes** — `nt_write_v(x, y, ...)` would set
`$2000` bit 2 (auto-increment 32) before the data writes and
clear it after. Useful for tilemap-driven scrolling.
- **Variable-length writes from a u8 array global** — today
`nt_fill_h` repeats one tile; a `nt_copy_h(x, y, src_var, len)`
variant that copies from a declared `u8[N]` global removes the
fill-only restriction.
- **Buffer-overflow detection** — the runtime drain assumes the
256-byte buffer never overflows. A debug-mode check that traps
when `head` would advance past `$04FF` would catch the worst
failure mode (writes wrapping into adjacent RAM).
### H. Metatiles + collision as a first-class construct
`metatileset Name { metatiles: [...] }` and `room Name { metatileset:
..., layout: [...] }` ship today — see `examples/metatiles_demo.ne`.
Each metatile bundles four CHR tile indices (TL / TR / BL / BR) plus
a `collide: true|false` flag; rooms lay them out as a 16×15 grid
that the compiler expands at compile time into a 32×30 nametable,
a 64-byte attribute table, and a 240-byte collision bitmap. All
three blobs land as PRG ROM data blocks with per-room labels
(`__room_tiles_N`, `__room_attrs_N`, `__room_col_N`).
`paint_room Name` reuses the existing `load_background` vblank-safe
update machinery against the room's tile/attribute blobs, and
additionally installs the room's collision bitmap pointer into
`ZP_ROOM_COL_LO` / `ZP_ROOM_COL_HI` (ZP `$18`/`$19`).
`collides_at(x: u8, y: u8) -> bool` JSRs into a small runtime
helper that reads `(room_col),Y` where `Y = (y & 0xF0) | (x >> 4)`
and returns the 0/1 bit directly. The helper is gated on a
`__collides_at_used` marker, so a program that declares a room but
never queries it pays zero bytes for the subroutine.
**Still TODO.**
- **NEXXT file import (`@room("file.nxt")`).** Today only inline
`layout: [...]` (or `[value; count]` for repeated fills) is
supported. The NEXXT exporter's `.nxt` format would let users
author rooms in a dedicated editor.
- **Per-quadrant palette hints.** The generated attribute table
is currently all zeros (sub-palette 0 for every quadrant). A
`palette: 0..3` field on each metatile entry could drive the
attribute table expansion.
- **`collides_at` against u16 coordinates.** The intrinsic takes
u8 x/y today, which covers the full 256×240 visible area but
can't address scrolling playfields bigger than one nametable.
- **Multi-room streaming.** The ZP pointer model already supports
multiple rooms (each `paint_room` rewrites the collision
pointer), but the nametable blit still uses the
nametable-0-only `load_background` path. Streaming into a VRAM
update buffer would let rooms swap without the vblank-budget
risk.
### I. RLE + LZ4 nametable decompression
`vram_unrle` and `vram_unlz4` — for scrolling/multi-room games,
packing rooms is mandatory. cc65 ships both in neslib with
concrete timing (0.5f RLE vs 2.8f LZ4). The per-state background
swapping item in "What ships today" is exactly this problem:
without a decompressor that can stream into the VRAM buffer, the
NMI-time write budget (~2273 cycles) is too tight for a full
nametable. RLE is the smaller first step — emit a `nametable` that
can declare `compression: rle` and decompress at swap time.
### J. Palette-fade brightness LUT follow-up
`set_palette_brightness(level: u8)` and the blocking
`fade_out(step_frames)` / `fade_in(step_frames)` builtins all ship
today — see `examples/palette_brightness_demo.ne` and
`examples/fade_demo.ne`. One follow-up still worth doing: a
brightness-LUT path that recolours the active palette in addition
to the emphasis bits, for non-NTSC-assumption fades. The current
implementation only manipulates `$2001` emphasis bits, so the
"dimmed" end of the fade still shows colour tint rather than a
true colour-space darken.
### M. Sprite cycling follow-ups
Auto sprite cycling ships today via `game { sprite_flicker: true }`
— the IR lowerer injects an `IrOp::CycleSprites` at the top of
every `on frame` handler when the flag is set. See
`examples/auto_sprite_flicker.ne`. A companion `draw ... priority:
pinned` modifier for HUD sprites that must stay at low OAM slots
is still missing — today pinning has to be manual (draw the HUD
sprites first).
### O. DPCM / DMC sample playback
Already listed under Audio Pipeline. FamiStudio's DMC support
(including bankswitched DMC) is the reference API shape — import
`@dpcm("file.dmc")` into a named sample slot and expose
`play_dpcm(Slot, pitch: u8, loop: bool)`.
### P. Expansion audio (VRC6, MMC5, FDS, N163, S5B, VRC7)
FamiStudio has a single export path with `FAMISTUDIO_CFG_EXTERNAL`
and per-chip feature flags. If/when we import a FamiStudio-export
format (see Q), the expansion chips come along almost for free
— the runtime just has to wire up the extra write ports and the
mapper has to expose them (MMC5 for the extra pulse channels,
VRC6/VRC7 via their own mappers).
### Q. FamiStudio text-export import
`@music("file.famistudio.txt")`. FamiStudio's text export is the
pragmatic ingestion path; parsing it gives full tracker semantics
(volume/pitch slides, arpeggios, vibrato, release notes) without
reinventing the engine. FamiTracker's binary `.ftm` is a worse
target — undocumented, version-skewed.
### R. NEXXT metatile/collision import
NEXXT is the dominant asset editor in the nesdoug workflow; it
emits metatile tables + collision maps as ca65-compatible
assembler source. An `@metatiles("room.nxt")` loader (and
`@room("level1.nxt")` for layouts — see §H) removes a whole class
of hand-typed tile arrays.
### S. SRAM / battery-backed saves follow-ups
`save { var ... }` ships today — see `examples/sram_demo.ne`. The
analyzer allocates save vars from a separate `$6000+` bump pointer,
the linker flips iNES byte-6 bit-1, and `W0111` warns when a save
var carries an initializer (SRAM is preserved across power cycles
so initializers would either silently not run or clobber the
player's data on every boot). Two follow-ups are still worth doing:
- **First-power-on detection** — an `on first_boot { ... }` handler
that runs once when a magic-byte sentinel is missing. Today users
have to roll the sentinel check by hand.
- **Struct fields in save blocks** — currently rejected because the
field-flattening path uses the main-RAM allocator. Routing it
through the SRAM allocator instead is a few lines of analyzer
refactor.
### T. PAL/NTSC region abstraction
Neslib exposes `ppu_wait_frame` as a virtual-50Hz wait on PAL. Add
a `region: ntsc | pal | dual` field on the `game { }` block. For
`dual`, the runtime probes `$2002` bit 7 timing at reset and sets a
ZP flag; the audio engine's frame tick and any frame-counted timing
respects the flag.
### U. Additional controller types
Expose Zapper (light-gun) and Power Pad via typed inputs:
```
input gun: zapper on port: 1
input mat: power_pad on port: 2
```
`gun.trigger`, `gun.light_detected`, `mat.button(i: u8)` are the
three reads every program needs.
### V. Additional mappers
AxROM (mapper 7), CNROM (mapper 3), and GNROM (mapper 66) all
ship today; see `examples/axrom_simple.ne`, `examples/cnrom_simple.ne`,
and `examples/gnrom_simple.ne`. CNROM and GNROM both have CHR
bankswitching but user-visible CHR swaps aren't reachable from
user source yet — the reset-time init writes bank 0 and the
`__bank_select` routine exists but has no user-exposed API. The
next set:
1. **MMC2** (mapper 9, Punch-Out only realistically). Medium.
2. **UNROM-512** (mapper 30). The modern homebrew sweet spot —
512 KB PRG + CHR-RAM + self-flashing. Mapping is UxROM-like
plus a one-screen bit.
3. **MMC5** (mapper 5). Big. Driven by FamiStudio's expansion
audio more than by the extra PRG/CHR modes. Probably last.
Each new mapper needs a `Mapper::X` variant, a reset-time
`gen_xrom_init()` in the runtime, bank-select support in
`gen_bank_select()`, and an iNES mapper number in `rom::mapper_number`.
The PR checklist ("example + behaviour test + negative test") is
still the bar for each of these.
### W. NSF output target
The audio engine is already a standalone subsystem. An NSF-output
target (`--target nsf`) would wrap the existing music/sfx blocks
in the NSF header and expose `init`/`play` entry points. Nearly
free, gets the chiptune audience for ~a day of work.
### X. Mesen trace-log documentation follow-up
`game { debug_port: fceux | mesen | 0xXXXX }` ships today — see
the integration test `debug_log_targets_configured_port`. Mesen's
trace-log tool invocation still isn't documented anywhere in the
NEScript docs; a short section under `docs/nes-reference.md`
walking through how to hook a Mesen trace-log against a ROM built
with `debug_port: mesen` would close the gap.
### Y. FCEUX `.ld` line-info follow-up
`--fceux-labels <prefix>` ships today and emits
`<prefix>.<bank-index>.nl` + `<prefix>.ram.nl`. FCEUX also reads a
`.ld` line-info file for source-level stepping; wiring that up
against the existing source-map data would close the loop without
much code.
### Z. Explicit bank-placement hints on functions and data
`bank Foo { fun bar() }` already exists; extend the sugar to
attributes on individual items so users don't have to restructure
their source:
```
@bank(3) fun slow_helper() { ... }
@bank(3) const LEVEL_DATA: u8[1024] = [...]
```
This is particularly useful for `const` data, which today lands
wherever the analyzer decides; users sometimes need to pin data
to a specific bank to avoid bank-switch cost on a hot path.
### Priority ranking
Remaining gap items in order of user value:
1. Register allocator (existing section) — compounding size win,
but with the audio-goldens caveat spelled out in that section.
2. NEXXT `@room(...)` import + per-quadrant palette hints
(§H follow-ups) — most impactful extensions on top of the
shipped metatiles base.
3. Inline-asm format specifiers + directive list (§D follow-ups).
4. VRAM buffer follow-ups (§G) — vertical writes, array copy,
overflow detection.
5. Arrays-of-structs + bitfields (§C) + fn pointers (§B) —
turns NEScript into a general-purpose NES language.
6. UNROM-512 + MMC5 (§V) — ecosystem fit.
7. FamiStudio import (§Q) + DPCM (§O) + expansion audio (§P).
---
## Open design questions
1. **Inline asm label syntax.** `.label:` (ca65 style) vs `label:` (generic)?
Today the inline-asm parser accepts `label:` but not `.label`; migrating
would be cheap but would invalidate any copy-pasted ca65 fragments.
2. **Debug port address.** $4800 is conventional but not universal. Should
we support multiple debug output methods?
3. **Pinned OAM slots for HUD sprites.** Automatic sprite cycling is
decided — both the manual `cycle_sprites` keyword and the
`game { sprite_flicker: true }` attribute ship today (see §M).
Open question: should we add a `draw ... priority: pinned`
modifier that opts a specific sprite out of the rotation so HUD
elements stay at low OAM slots?
4. **Error recovery granularity.** How aggressively should the parser
recover? More recovery means more errors per compile but also risks
cascading false errors.