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

docs: catalog cc65/nesdoug parity gaps in future-work.md

Enumerates the gaps between NEScript today and what the cc65/nesdoug
ecosystem exposes: i16/pointers/bitfields, VRAM update buffer,
metatiles, edge-triggered input, PRNG, palette fade, sprite-0 split,
additional mappers (AxROM/CNROM/UNROM-512/MMC5), FamiStudio import,
SRAM saves, PAL/NTSC abstraction, NSF output, Zapper/Power Pad,
configurable debug port, FCEUX .nl labels, and explicit bank hints.
Each item has a design sketch and the section ends with a priority
ranking. This is the planning doc the follow-up implementation
commits will chip away at.
This commit is contained in:
Claude 2026-04-18 17:41:26 +00:00
parent 33b5a3958b
commit c96135fd86
No known key found for this signature in database

View file

@ -238,6 +238,355 @@ 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 / bool`
- **`i16`.** The smallest change with the highest blast radius.
Negative metasprite offsets, signed velocities, signed scroll
deltas, subtraction-of-positions — none of that works today
without underflow hazards. Design sketch:
- Lexer: no change (type names are already identifiers).
- Parser: add `i16` to the primitive-type list.
- Analyzer: extend `Type` + coercion table; signed×unsigned
mixing should require an explicit cast.
- IR: `Add/Sub/Cmp` already carry a signedness flag for `i8`;
extend the 16-bit variants the same way.
- Codegen: `CMP`/`BCC`/`BCS` for unsigned vs `BMI`/`BPL`/signed
compare for signed (XOR the high bits before subtract, or
branch on the overflow flag).
- **`u32` / `i32`.** Lower priority; 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
- Today the inline-asm lexer accepts `label:` but not `.label:`
(ca65 style). Port the lexer to accept `.`-prefixed local
labels and emit them as ca65-compatible locals (`@label`).
- 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 primitive
The highest-leverage missing runtime feature. Today
`load_background` / `set_palette` queue PPU writes under the hood,
but there is no user-visible "write these N bytes into nametable
slot `(x,y)` next vblank" primitive. That's the idiom behind every
scoreboard, dialog box, destroyed-metatile animation, and streaming
scroll in the nesdoug chapters. Concrete API sketch:
```
buffer.nametable_write(x, y, [0x20, 0x21, 0x22]) // horizontal
buffer.nametable_write_v(x, y, [0x20, 0x21, 0x22]) // vertical
buffer.attribute_write(x, y, 0b00011011) // one byte
buffer.flush() // force an eof
```
Runtime shape: a fixed ring buffer at a known RAM address
(`$0400`?). Each entry is `[header, addr_hi, addr_lo, len, data…]`
where `header` carries the `NT_UPD_HORZ` / `NT_UPD_VERT` /
`NT_UPD_EOF` bits the neslib engine already uses. The NMI handler
drains the buffer every frame and writes `$FF` as the sentinel.
### H. Metatiles + collision as a first-class construct
cc65/nesdoug treats 2×2 metatiles + a parallel collision map as
the core room format. `docs/future-work.md` mentions "tilemap
collision queries"; raise the scope to a single cohesive feature:
```
metatileset DirtWorld {
source: @tiles("dirt.chr"),
metatiles: [
{ id: 0, tiles: [0, 1, 16, 17], collide: false },
{ id: 1, tiles: [2, 3, 18, 19], collide: true },
...
],
}
room Level1 {
metatileset: DirtWorld,
layout: @room("level1.nxt"), // NEXXT exporter format
}
on_frame {
if collides_at(hero.x, hero.y) {
...
}
}
```
The compiler would expand each `room` into a packed `[(metatile_id
<< 4 | collision_bits), ...]` blob in PRG ROM, emit a
`collides_at(x: u16, y: u16) -> bool` helper, and stream the
expanded tiles into the VRAM update buffer on a `paint_room()` call.
### 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 brightness / fade
Neslib's `pal_bright(level: 0..8)` is a one-call fade that flips
the PPU mask emphasis bits and optionally darkens the active
palette via a brightness LUT. One-call fade-in / fade-out is
enormous polish for nearly no runtime cost. API:
```
builtin set_palette_brightness(level: u8) // 0=off, 4=normal, 8=white
builtin fade_out(frames: u8) // blocks; drives mask bits
builtin fade_in(frames: u8)
```
### K. Edge-triggered input
Today NEScript exposes level-state buttons (`p1.a` is whatever the
hardware reports this frame). Every menu needs a "just pressed
this frame" primitive. Extend the button type with `.pressed` and
`.released` accessors:
```
if p1.a.pressed { menu_accept() }
if p1.start.released { pause_menu() }
```
Implementation is one more ZP byte per controller (`p1_prev`) and
an XOR in the input polling stub.
### L. Sprite 0 hit split-screen
`split(x, y)` is the neslib primitive for a fixed status bar above
a scrolling playfield without MMC3. NEScript only offers
`on_scanline(N)` on MMC3. A sprite-0-hit-based split that works on
NROM/UxROM/MMC1 unlocks most of the tutorial games. API:
```
sprite_0_split scanline: 32, {
scroll_x: 0,
scroll_y: 0,
}
```
…emits a busy-wait on `$2002` bit 6 followed by the requested
scroll write.
### M. Automatic sprite cycling
The existing `cycle_sprites` opt-in keyword rotates the DMA offset
each frame. A `game { sprite_flicker: true }` attribute that emits
the rotation automatically — plus a `draw ... priority: pinned`
modifier for HUD sprites that must stay at low OAM slots — is the
cleaner user-facing API. Mentioned already under Open Design
Questions; bumping it into the active roadmap.
### N. Runtime PRNG
`rand8()` / `rand16()` / `set_rand(seed: u16)` are in every
nesdoug demo. Implement as a ZP-held 16-bit LFSR (xorshift16 is
tiny and good enough). Users shouldn't have to hand-roll this.
### 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
Already in the spec as a "reserved for future versions" item. Add
a top-level `save { var … }` block that lands its allocations at
`$6000+`, flips the iNES battery flag, and exposes the allocations
to the rest of the program as if they were ordinary globals (with
a compiler-emitted checksum on write to survive cold starts).
### 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
In priority order (cheapest × highest demand first):
1. **AxROM** (mapper 7). Single-screen mirroring, up to 256 KB PRG
bankswitched in 32 KB pages. Almost a trivial extension of the
UxROM path — one register, different mirroring bit.
2. **CNROM** (mapper 3). 8 KB CHR bankswitching, fixed 32 KB PRG.
One register, CHR-only. Also trivial.
3. **GNROM / MHROM** (mapper 66). Combines AxROM-style PRG with
CNROM-style CHR banking. Another single-register mapper.
4. **MMC2** (mapper 9, Punch-Out only realistically). Medium.
5. **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.
6. **MMC5** (mapper 5). Big. Driven by FamiStudio's expansion
audio more than by the extra PRG/CHR modes. Probably last.
### 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. Configurable / Mesen-native debug output
Today the debug port is hardcoded to `$4800`. Expose
`debug.port: $4800 | mesen` on the `game { }` block. For
`mesen`, emit writes to `$4018` (Mesen's documented debug port)
and document the trace-log tool invocation in the debug docs.
### Y. FCEUX `.nl` / `.ld` label file output
`--dbg` writes ca65-compatible debug info, which Mesen + Mesen2 +
FCEUX all consume for source-level stepping. FCEUX also supports
its native `.nl` (per-bank label) / `.ld` (line) files, which some
users prefer. Cheap addition: `--fceux-labels <prefix>` emits
`<prefix>.0.nl`, `<prefix>.1.nl`, …, `<prefix>.ld`.
### 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
In practice the order I'd tackle these for maximum user value:
1. `i16` (§A) — unblocks signed physics, metasprite offsets.
2. VRAM update buffer (§G) — unblocks HUDs, dialog, streaming.
3. Edge-triggered input (§K) + PRNG (§N) — one-line demo wins.
4. Palette fade (§J) + sprite-0 split (§L) — cheap polish wins.
5. Register allocator (existing section) — compounding size win.
6. Metatiles + collision (§H) — closes several items at once.
7. Inline-asm completeness (§D) — escape hatch for power users.
8. Arrays-of-structs + bitfields (§C) + fn pointers (§B) —
turns NEScript into a general-purpose NES language.
9. SRAM (§S) + AxROM/CNROM/UNROM-512 (§V) — ecosystem fit.
10. FamiStudio import (§Q) + DPCM (§O) + expansion audio (§P).
---
## Open design questions ## Open design questions
1. **Inline asm label syntax.** `.label:` (ca65 style) vs `label:` (generic)? 1. **Inline asm label syntax.** `.label:` (ca65 style) vs `label:` (generic)?