The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.
This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:
## Asset pipeline (new `src/assets/audio.rs`)
- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
pulse-1 envelopes. Pitch/volume arrays must match in length; each
entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
`start_music` references and append builtin fallbacks for any
name that isn't user-declared — so `play coin` still works
without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
and tracks (theme, battle, victory, gameover) synthesize through
the same compile path as user decls — one data model, one driver.
## Runtime engine (`src/runtime/mod.rs`)
- `gen_audio_tick()` walks both channels every NMI: reads one
envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
advances ptr, mutes on zero sentinel. Music decrements the note
counter, advances to the next `(pitch, dur)` pair on zero, looks
up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
(A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
user sfx/music data can be spliced into PRG with regular labels
that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
(duty/volume/loop/active), `$0C-$0F` sfx and music pointers.
## IR codegen (`src/codegen/ir_codegen.rs`)
- `with_audio(sfx, music)` registers compile-time trigger constants
per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
`__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
with the active bit OR'd in, seeds both ptr and loop base from
`__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.
## Linker (`src/linker/mod.rs`)
- New `link_with_all_assets(user_code, sprites, sfx, music)` path
that splices driver body, period table, and each sfx/music data
blob into PRG — all guarded on the `__audio_used` marker so
silent programs pay zero ROM cost.
## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)
- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
letting the linker splice ROM data tables into a code section
and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
correctly in the same assembly pass.
## Analyzer
- `play` / `start_music` now validate the name against user decls
and builtin tables. Unknown names emit E0505 with a helpful list
of builtins — previously a typo would silently compile to no-op.
## Parser
- New `sfx_decl` / `music_decl` grammar with property-style
configuration. Strict validation: duty 0-3, volume 0-15, pitch
arrays must match volume length, music notes must come in pairs,
pitch 0-60, duration ≥ 1.
## Tests
+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
$4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
user-declared `sfx` + `music` program that verifies bytes land
in PRG ROM at the right addresses
## Docs
- New Audio section in `docs/language-guide.md` with syntax
reference, builtin tables, and an explanation of how the
driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
"status: full subsystem" with a narrower list of follow-up work
(triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
`sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
builtin fallback via `play coin`.
Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
24 KiB
Future Work
This document catalogs known gaps, incomplete features, and planned improvements in the NEScript compiler. Items are organized by priority and area.
1. IR-Based Code Generation
Status: Complete. The AST → IR lowering, optimizer, and
src/codegen/ir_codegen.rs all work end-to-end; the legacy AST
codegen has been removed. See "Recently completed" below.
3. Sprite Name Resolution
Status: draw SpriteName at: (x, y) parses the sprite name but codegen
ignores it. All draws use OAM slot 0 with CHR tile index 0 (the built-in smiley).
What's needed:
- Track a mapping from sprite name → CHR tile index in the linker
- When a
spritedeclaration provides inline CHR data or@chr("file.png"), write that data to the CHR ROM at a known tile index - In codegen, look up the sprite name to get the tile index and write it to OAM byte 1 (tile number)
- Support multiple OAM slots: track a
next_oam_slotcounter per frame, allocate slots 0..63 as draws are emitted, warn if >64
4. Multi-OAM Sprite Support
Status: Every draw writes to the same OAM bytes ($0200-$0203). Only one
sprite is visible at a time.
What's needed:
- Frame-level OAM slot allocator: each
drawgets the next available 4-byte slot - Counter reset at the start of each frame handler
- Warn at compile time if a frame handler has >64 static draws
- Runtime: clear unused OAM slots to Y=$FE (off-screen) at frame start
5. State Machine Dispatch
Status: The codegen only generates code for the start state. Other states
are parsed and analyzed but their frame handlers are never called.
What's needed:
- A
current_statezero-page variable holding the active state index - A dispatch table at the start of the main loop: load
current_state, branch to the corresponding state's frame handler transition StateNamewrites the new state index tocurrent_state- Generate
on_entercall on transition,on_exitcall before leaving
6. Include Directive
Status: The include keyword is lexed (KwInclude) but not parsed or
implemented.
What's needed:
- Parser:
include "path.ne"at top level - File resolution: relative to the including file's directory
- Circular include detection (track include stack)
- Merge included declarations into the main
ProgramAST - Span tracking: included files need their own
file_idfor error messages
7. Debug Mode
Status: --debug flag is accepted by the CLI but has no effect.
What's needed:
debug.log(expr, ...): write values to emulator debug port ($4800)debug.assert(expr): emit runtime check, halt on failuredebug.overlay(x, y, text): render text to a reserved nametable region- Array bounds checking in debug mode (compare index against array size)
- Frame overrun detection: count cycles per frame, warn if approaching vblank
- All debug code stripped in release mode (already designed:
DebugLog/DebugAssertin theStatementenum are defined in the spec but not yet in the AST)
8. Scroll Hardware Writes
Status: scroll(x, y) is parsed but produces no output.
What's needed:
- Write X scroll value to PPU register $2005
- Write Y scroll value to PPU register $2005 (second write)
- Must happen during vblank (inside NMI handler or after
wait_frame) - Split-screen scroll requires MMC3 scanline IRQ (
on scanline)
9. Asset Pipeline Completion
Status: PNG → CHR conversion exists (src/assets/chr.rs) but is never called
from the compilation pipeline.
9a. Wire @chr("file.png") to actual PNG loading
- When a sprite/background declares
chr: @chr("path.png"), callpng_to_chr()during compilation - Resolve the path relative to the source file
- Store resulting CHR data in the ROM's CHR section at a known tile index
9b. Wire @binary("file.bin") to raw file inclusion
- Read the file as raw bytes and include in CHR or PRG ROM
9c. Palette extraction from PNG
@palette("file.png"): analyze image colors, map to nearest NES palette entries- Already have
nearest_nes_color()insrc/assets/palette.rs
9d. Nametable conversion
- Full 256×240 PNG → 960-byte nametable + 64-byte attribute table
- Tile deduplication (max 256 unique tiles per pattern table)
- Error if >256 unique tiles
10. Error Message Polish
Status: Errors work and render with ariadne, but many error paths use generic messages.
Unused error codes
These are defined in ErrorCode but never emitted:
E0202— invalid castE0203— invalid operation for typeE0301— zero-page overflowE0403— unreachable stateE0505— multiple start declarationsW0101— expensive multiply/divide operationW0102— loop without break or wait_frameW0103— unused variableW0104— unreachable code after return/break/transition
Missing validations
- No error for assigning to a
const - No error for
break/continueoutside a loop - No warning for variables declared but never read
- No error for
returnwith wrong type vs function signature - No error for calling a function with wrong argument count/types
11. Scanline IRQ (MMC3)
Status: on scanline(N) is in the spec and on_scanline field exists in
StateDecl, but parsing and codegen are not implemented.
What's needed:
- Parser:
on scanline(N) { ... }event handler in state bodies - MMC3 IRQ setup: write scanline counter to $C000/$C001/$E000/$E001
- IRQ handler generation: branch to the scanline handler code
- Only valid with
mapper: MMC3
12. Audio
Status: A full data-driven audio subsystem is in place. User programs
can declare sfx Name { duty, pitch, volume } blocks (frame-accurate
pulse-1 envelopes) and music Name { duty, volume, repeat, notes }
blocks (pulse-2 note streams with rests and looping). The resolver
compiles these into ROM-ready byte tables; the IR codegen emits
trigger sequences that load pointers into ZP; the runtime NMI tick
walks the envelope and note stream each frame, indexing into a
builtin 60-note period table. Builtin effects (coin, jump, hit,
click, cancel, shoot, step) and tracks (theme, battle,
victory, gameover) are synthesized from the same data path so
programs that don't declare their own audio still make sound.
Programs that touch no audio pay zero ROM or cycle cost — the whole
subsystem elides when the __audio_used marker is absent.
Still TODO for richer audio:
- Triangle/noise/DMC channels (currently only pulse 1 and 2 are used)
- Multi-channel tracker playback (one
noteslist per channel) @sfx("file.nsf")/@music("file.ftm")asset directives- FamiTracker export format parsing
- Per-note pitch changes within a sfx (currently pitch is latched once)
13. Language Features (Post-v0.1)
From the spec's "Reserved for Future Versions" section:
| Feature | Description |
|---|---|
| Structs | struct Vec2 { x: u8, y: u8 } — composite types with known layout |
| Enums | enum Direction { Up, Down, Left, Right } — mapped to u8 values |
| Fixed-point | fixed8.8 type for sub-pixel movement |
| Text/HUD | Font sheet declarations, layout system for scores/health/menus |
| Metasprites | Multi-tile sprite groups with relative positioning |
| Tilemaps | Declarative level data with collision queries |
| SRAM/saves | Persistent storage declarations for battery-backed save data |
| NES 2.0 | Extended iNES header format |
14. Inline Assembly
Status: asm { } blocks are lexed (KwAsm, AsmBody) but not parsed or
compiled. The lexer has raw-capture mode for asm content.
What's needed:
- Parser: capture asm body text, parse
{variable_name}substitutions - Codegen: emit raw 6502 instructions with variable address substitution
- Labels: local to the asm block scope
raw asm { }variant with no substitution or safety checks
15. Compiler Performance
Status: Compilation is fast (<100ms for all examples) but has no benchmarks.
What's needed:
cargo benchbenchmarks for each pipeline phase- Regression test: compilation must stay under 500ms for any reasonable project
- Profile-guided optimization of hot paths (lexer, parser)
16. WASM Build Target
What's needed:
- Factor out all file I/O behind a trait (
FileSystem/VFS) - Core pipeline takes
&strsource →Vec<u8>ROM with no filesystem access - Compile the compiler to WASM for a browser-based IDE
- In-browser NES emulator integration for instant preview
17. Open Design Questions
From the engineering plan:
- Inline asm label syntax:
.label:(ca65 style) vslabel:(generic)? - Debug port address: $4800 is conventional but not universal. Support multiple debug output methods?
- OAM allocation strategy: Sequential allocation vs priority-based with automatic sprite cycling for the 8-per-scanline limit?
- Error recovery granularity: How aggressively should the parser recover? More recovery = more errors per compile, but risk of cascading false errors.
18. Missing Assignment Operators
<<= and >>= (shift-assign) are lexed as ShiftLeftAssign/ShiftRightAssign
tokens but have no corresponding variants in the AssignOp AST enum. They can
never appear in parsed code. Adding them requires:
- New
AssignOp::ShiftLeftAssignandAssignOp::ShiftRightAssignvariants - Parser handling in
parse_assign_or_call(alongside the other compound ops) - Codegen: load value, shift, store back
19. Player 2 Controller
Player::P1 and Player::P2 are defined in the AST but marked #[allow(dead_code)].
The parser always produces ButtonRead(None, ...) — it never parses p1.button.X
or p2.button.X syntax. The runtime only reads controller 1 ($4016).
What's needed:
- Parser:
p1.button.Xandp2.button.Xsyntax producingPlayer::P1/P2 - Runtime: read controller 2 from $4017 into a second zero-page byte
- Codegen: select the correct input byte based on player
20. Register Allocator
The plan describes src/codegen/regalloc.rs for managing A/X/Y allocation, but
no register allocator exists. The current codegen uses A for everything and
spills to zero-page $02 for comparisons. A proper allocator would:
- Track A/X/Y liveness across basic blocks
- Use X for array indexing and loop counters
- Use Y as secondary index
- Spill to zero-page temps only when all three are live
Priority Order
Recently completed (removed from backlog)
These items were documented as future work but have since been implemented:
-
Full audio subsystem —
src/runtime/mod.rs::gen_audio_tick,gen_period_table, andgen_data_blockimplement a frame-walking pulse driver.src/assets/audio.rscompiles usersfx/musicdeclarations (and builtins referenced viaplay coinetc.) into ROM-ready envelope and note-stream byte tables.IrCodeGen::with_audiothreads the compile-time trigger constants intoplay/start_music, which emit pointer loads against per-blob labels. The linker splices driver body, period table, and every blob into PRG gated on the__audio_usedmarker so silent programs pay no cost. Full parser/analyzer/codegen/linker/runtime test coverage. -
u16 arithmetic and comparisons — new IR ops
LoadVarHi,StoreVarHi,Add16,Sub16,CmpEq16throughCmpGtEq16. The lowering context tracks variable types via the analyzer's symbol table and routes each expression through the 8-bit or 16-bit path based on operand width. Initializers, compound assignments, and comparisons all preserve both bytes. The codegen emitsCLC;ADC;ADCfor Add16 with carry propagating naturally, and compare-high-then-compare-low dispatch for the six comparison variants. -
Multi-scanline on_scanline per state —
gen_scanline_irqnow dispatches on(current_state, ZP_SCANLINE_STEP)and reloads the MMC3 counter with the delta to the next scanline in the same state.gen_scanline_reloadresets the step counter at the top of each NMI so a state with multiple handlers fires them in ascending line order. -
IR temp slot recycling —
build_use_countspre-scans each function to count per-temp uses;retire_op_sourcesdecrements the counts after each op runs and pushes dead slots back ontofree_slotsfor later allocation. Previously,bitwise_ops.necrashed (debug) or silently miscompiled (release) once it allocated more than 128 concurrent temps. With recycling the same function now uses ~4 slots instead of 136. -
INC/DEC peephole fold —
fold_inc_deccollapsesLDA addr; CLC; ADC #1; STA addrinto a singleINC addr(and the SEC/SBC/#1 variant intoDEC addr). Saves 5 bytes and 5 cycles per increment. The fold is suppressed when the next instruction is a carry-dependent branch (BCC/BCS) since INC/DEC don't update the carry flag. -
Peephole dead-load elimination across passive ops — the old
remove_dead_loadsonly dropped an LDA if the very next instruction was another A-writer. Now it walks past INC/DEC/STX/STY (which don't touch A) to find the actual next A-use, catching more dead loads produced by copy propagation. -
State machine dispatch — CMP + BNE + JMP trampoline dispatch table,
current_statein ZP $03, on_enter/on_exit handlers as JSR targets -
Function call codegen — JSR to function labels, ZP $04-$07 param passing, RTS for returns
-
Break/continue — loop_stack with JMP to continue/break labels
-
Return — evaluate expr to A + RTS
-
Transition — write state index + JMP to main loop
-
Array indexing — TAX + LDA/STA with ZeroPageX or AbsoluteX
-
Scroll — PPU $2005 writes (X then Y)
-
Multiply/divide/modulo — JSR __multiply/__divide with shift-and-add/restoring division
-
Shift left/right — ASL A / LSR A
-
Multi-OAM sprites — sequential slot allocation (0-63), reset per frame
-
Const assignment error — E0203 for assigning to constants
-
Break outside loop error — E0203 for break/continue without enclosing loop
-
Math routines wired into linker — gen_multiply/gen_divide included in ROM
-
Sprite name resolution — sprite declarations map to CHR tile indices, draw statements use the correct tile number
-
Inline sprite CHR data — sprite decls with
chr: [0x..., ...]work -
Include directive —
include "path"inlines files at parse time, with circular include detection -
Shift-assign operators —
<<=and>>=work in all contexts -
Player 2 controller —
p1.button.X/p2.button.Xsyntax, P2 input read from $4017 into ZP $08 -
Unused variable warning — W0103 emits for declared-but-never-read globals (underscore-prefix silences)
-
Unreachable state warning — W0104 emits for states not reachable from the start state via transitions
-
E0502 "did you mean" suggestions — undefined variable errors include a suggestion for nearby-named symbols
-
debug.log / debug.assert — parses into
Statement::DebugLog/Statement::DebugAssert, codegen emits runtime writes to $4800 when--debugis set, stripped in release mode -
--debug CLI flag wired — threads through
IrCodeGen::with_debug -
IR-based codegen —
src/codegen/ir_codegen.rswalksIrProgramand emits 6502 for every IR op: load/store, arithmetic, comparisons, arrays, calls, draws, input (P1 and P2), scroll, debug.log/assert, state dispatch, runtime OAM cursor for looped draws, transitions + on_enter handlers. It's the only codegen — the legacy AST-based path and the--use-astflag were removed once the IR pipeline was proven correct by the jsnes emulator smoke test. -
IR lowering bug fixes —
ReadInputnow has a destination temp,ButtonReaduses the proper input temp, logical AND/OR use a newemit_movehelper instead of the buggy raw VarId temp storage -
IR Player 2 controller —
ReadInput(temp, player)selects $01 or $08 based on player index -
IR scroll support —
scroll(x, y)lowers toIrOp::Scroll(x, y)which emits two PPU $2005 writes in IR codegen -
IR debug.log / debug.assert — new
IrOp::DebugLog(temps)andIrOp::DebugAssert(cond)variants, emitted as $4800 writes in debug mode and stripped in release -
Asset pipeline @binary / @chr loading —
resolve_sprites()reads raw binary files and converts PNGs viapng_to_chr(). Missing files are silently skipped (documentation-friendly) -
Call arity validation — E0203 when
Statement::CallorExpr::Callhas the wrong number of arguments or a mismatched argument type (uses afunction_signaturesmap) -
Return type validation —
return valueis type-checked against the function's declared return type (E0201); returning a value from a void function emits E0203 -
W0102 loop-without-yield warning — emitted when a
loop { ... }body contains nobreak,return,transition, orwait_frame -
W0101 expensive mul/div/mod warning — flags multiply/divide/modulo with two non-constant operands; literal operands are silent because the optimizer strength-reduces them
-
W0104 dead-code-after-terminator warning — statements after
return,break,continue, ortransitionin the same block emit W0104 with a label pointing at the terminator -
E0301 RAM overflow — the zero-page user region is now bounded above by
$80(leaving$80-$FFfor IR temps) and the main RAM allocator stops at$0800; overflow emits E0301 at the declaration -
E0505 multiple start declarations — parser rejects a second
start Xtoken -
on scanline(N)handlers —state { on scanline(240) { ... } }parses and populatesStateDecl::on_scanline; analyzer emits E0203 if the game isn't using MMC3. The IR codegen now emits the full MMC3 IRQ vector glue: per-state dispatch in__irq_userand a__ir_mmc3_reloadhelper that picks the right$C000latch value based oncurrent_state. Seeexamples/scanline_split.neandexamples/mmc3_per_state_split.ne. -
Inline assembly —
asm { ... }blocks. The lexer captures the body as a rawAsmBodytoken;src/asm/inline_parser.rsprovides a minimal 6502 mnemonic parser that handles every addressing mode the codegen emits. The IR codegen splices parsed instructions directly into the output stream -
Enum types —
enum Name { V1, V2, ... }declares u8 constants with values equal to declaration order. Variant names are flattened into the global symbol table -
Struct types —
struct Vec2 { x: u8, y: u8 }with field access (pos.x = 5) and contiguous u8-only field layout. The analyzer synthesizes per-field VarAllocations so the rest of the compiler treats field access as ordinary variable access. -
for i in start..end { ... }loops — half-open range with a u8 index variable. Desugared in IR lowering to a while loop with a proper continue-edge block sobreak/continuework. -
Audio subsystem — full data-driven pulse driver with
sfx/musicblock declarations, builtin effects, and an NMI-time tick that walks envelope and note-stream tables. See section 12 above for the full writeup. -
Constant expression folding —
const B: u8 = A + 3evaluates at compile time and feeds through to variable initializers too. -
on scanlinecodegen (minimal) — MMC3 IRQ setup at startup plus a__irq_userdispatcher that saves registers, ACKs via$E000, dispatches oncurrent_stateto the right scanline handler, and restores.__ir_mmc3_reloadhelper re-arms the counter each frame from NMI. -
Peephole optimizer —
src/codegen/peephole.rsruns to fixed point after codegen. Current passes:- copy propagation for IR temps (rewrites loads to their source)
- dead-LDA elimination (drops overwritten LDAs)
- redundant STA/LDA pair removal
- LDA-then-STA-same-address removal
- dead-store elimination for IR temp slots (function-wide scan)
- A-value tracking eliminates redundant LDAs (ZP and absolute)
- branch folding:
Bxx L; JMP M; L:→Byy M - dead JMP to next label removal
-
--dump-irCLI flag — prints the lowered IR program after the optimizer pass for debugging -
Function-local variables — IR codegen allocates backing storage for
vars declared inside function bodies, using a per-function RAM range at$0300+so nested calls don't clobber each other. -
E0502 on assignment to undefined variable — previously was silently creating a new variable.
-
Function call ABI fix — IR codegen was JSRing to
__fn_namebut functions were defined as__ir_fn_name, and param VarIds weren't invar_addrsso callees read temp slots instead of parameters. Both bugs are now fixed with an integration test guard. -
Struct literal syntax —
Vec2 { x: 100, y: 50 }in both variable initializers and assignments. Desugars in lowering to per-field stores. Restricted to non-condition expression contexts (if/while/for conditions) to avoid ambiguity with block{. -
Match statement —
match x { pat => body, _ => default }parses to an if/else-if chain at parse time, so no new AST variant is needed. Supports any expression patterns and an underscore catch-all. -
For loops —
for i in start..end { body }(half-open range). Desugars in IR lowering to a while loop with a proper continue-edge block. -
Semicolon statement separators — short statements can share a line:
a += 1; b += 2. -
Inline asm
{var}substitution — inside anasm { ... }block,{name}is replaced with the hex address of the variablename. The lexer balances nested braces so{counter}inside an asm body is captured correctly. -
raw asm { ... }blocks — variant of inline asm that skips{var}substitution, passing the body through verbatim. -
poke(addr, value)/peek(addr)intrinsics — hardware register access without needing an asm block. Compile to a single LDA/STA against a compile-time-constant address. -
--memory-mapCLI flag — prints a human-readable variable allocation table showing what's in ZP vs main RAM. -
--call-graphCLI flag — prints a call-tree view with max depth reached from each entry point handler.
Remaining priority order
For someone picking up this codebase, the recommended order of work:
- u16 / array / nested struct fields — u16 globals now work end-to-end (load/store, +/-, comparisons all propagate through 16-bit IR ops). Struct fields and array elements are still u8-only — the layout machinery needs to grow multi-byte field offsets.
- Triangle / noise / DMC channels — the current audio engine
plays sfx on pulse 1 and music on pulse 2 with a full
data-driven tracker model (envelope walk, period table,
(pitch, duration)note streams, loop-back). Wiring triangle and noise channels into the same model would unblock richer multi-part compositions. - Register allocator — proper A/X/Y allocation to replace zero-page spills used by the current IR codegen. Partially mitigated by peephole passes + the new slot recycler, but still wasteful in some cases (every temp spills to a ZP slot even if its live range is one op wide).
- Text / HUD layer — font sheet + layout system for scores.
- Cross-block temp live-range analysis — the current slot recycler is function-local; temps that flow across block boundaries always get a dedicated slot for the full function. A proper CFG-aware live range interference graph would let more temps share slots.
- Peephole: drop LDA dead across unconditional JMPs — after
the INC/DEC fold we sometimes leave an
LDA #1whose value is consumed by nothing before the nextJMP __ir_main_loop. Local analysis can't prove it's dead; a cross-block pass could.