diff --git a/README.md b/README.md index cd98ea4..d166b8b 100644 --- a/README.md +++ b/README.md @@ -41,20 +41,20 @@ start Main ## Features -- **Game-aware syntax** -- states, sprites, palettes, and input are first-class constructs +- **Game-aware syntax** -- states, sprites, and input are first-class constructs - **Full type system** -- `u8`, `i8`, `u16`, `bool`, fixed-size arrays (`u8[N]`), `enum`, `struct` - **Rich control flow** -- `if`/`else`, `while`, `for i in 0..N`, `loop`, `match` - **Functions** -- with parameters, return types, `inline` hint, recursion detection - **State machines** -- `state` with `on enter`, `on exit`, `on frame`, `on scanline(N)` handlers - **Compile-time safety** -- call depth limits, recursion detection, type checking, unused-var warnings -- **IR-based optimizer** -- constant folding, dead code elimination, strength reduction, copy propagation, peephole passes including INC/DEC fold and live-range slot recycling +- **IR-based optimizer** -- constant folding, dead code elimination, strength reduction (incl. div/mod by power-of-two), copy propagation, peephole passes including INC/DEC fold and live-range slot recycling - **Full 16-bit arithmetic** -- u16 add/sub/compare lower to carry-propagating paired operations - **Multiple mappers** -- NROM, MMC1, UxROM, MMC3 (including multi-scanline IRQ dispatch per state) - **Audio subsystem** -- frame-walking pulse driver with user-declared `sfx`/`music` blocks, builtin effects and tracks, period table, and zero-cost elision when unused -- **Asset pipeline** -- PNG-to-CHR conversion, palette definitions, inline tile data, sfx envelopes, music note streams +- **Asset pipeline** -- PNG-to-CHR conversion, inline tile data, sfx envelopes, music note streams - **Inline assembly** -- `asm { ... }` with `{var}` substitution, plus `raw asm { ... }` for verbatim blocks - **Hardware intrinsics** -- `poke(addr, value)` / `peek(addr)` for direct register access -- **Debug support** -- `--debug` flag, source maps, Mesen-compatible symbol export, `debug.log` / `debug.assert` +- **Debug support** -- `--debug` flag enables `debug.log` / `debug.assert` writes to the emulator debug port - **Compile-time diagnostics** -- `--dump-ir`, `--memory-map`, `--call-graph` flags - **Single binary** -- no dependencies on ca65, Python, or any external tools @@ -74,7 +74,7 @@ start Main | [`coin_cavern.ne`](examples/coin_cavern.ne) | Multi-state game, functions, constants, gravity | | [`arrays_and_functions.ne`](examples/arrays_and_functions.ne) | Arrays, functions, while loops, inline functions | | [`state_machine.ne`](examples/state_machine.ne) | State transitions, on enter/exit, timers | -| [`sprites_and_palettes.ne`](examples/sprites_and_palettes.ne) | Inline CHR data, palettes, scroll, type casting | +| [`sprites_and_palettes.ne`](examples/sprites_and_palettes.ne) | Inline CHR data, scroll, type casting | | [`mmc1_banked.ne`](examples/mmc1_banked.ne) | MMC1 mapper, bank declarations, multiply | | [`structs_enums_for.ne`](examples/structs_enums_for.ne) | Structs, enums, `for` loops, struct literals | | [`inline_asm_demo.ne`](examples/inline_asm_demo.ne) | Inline asm with `{var}` substitution, `poke`/`peek` | @@ -115,11 +115,11 @@ NEScript implements all five planned milestones: |-----------|--------|-------------| | M1: Hello Sprite | Done | Full compiler pipeline, assembler, ROM builder | | M2: Game Loop | Done | Functions, arrays, IR, optimizer, call graph analysis | -| M3: Asset Pipeline | Done | PNG-to-CHR, sprites, palettes, debug symbols | +| M3: Asset Pipeline | Done | PNG-to-CHR, sprites, `debug.log` / `debug.assert` | | M4: Optimization | Done | Strength reduction, ZP promotion, type casting, asm-dump | | M5: Bank Switching | Done | MMC1/UxROM/MMC3, bank declarations, software mul/div | -210 tests across 14 modules, with CI running fmt, clippy, test, and example compilation on every push. +465 tests across the lexer, parser, analyzer, IR, optimizer, codegen, assembler, linker, runtime, ROM, and asset modules, with CI running fmt, clippy, test, and example compilation on every push. ## License diff --git a/docs/architecture.md b/docs/architecture.md index d70c84d..a70651c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,19 +7,11 @@ An overview of the NEScript compiler internals for contributors and maintainers. ## Pipeline ``` -Source (.ne) --> Lexer --> Parser --> Analyzer --> IR Lowering --> Optimizer --> Codegen --> Assembler --> Linker --> ROM (.nes) +Source (.ne) --> Lexer --> Parser --> Analyzer --> IR Lowering --> Optimizer --> Codegen --> Peephole --> Linker --> ROM (.nes) ``` Each phase is a pure function (input to output) with no global state, making every stage independently testable. -``` -.ne source ---------> Lexer -----> Parser -----> Analyzer ------> - (tokens) (AST) (annotated AST) - -IR Lowering -----> Optimizer -----> Codegen -----> Linker -----> ROM - (IR) (optimized IR) (6502 insns) (.nes file) -``` - ### Phase Summary | Phase | Input | Output | Responsibility | @@ -28,53 +20,52 @@ IR Lowering -----> Optimizer -----> Codegen -----> Linker -----> ROM | **Parser** | Token stream | AST | Syntax validation, tree construction | | **Analyzer** | AST | Annotated AST | Type checking, scope resolution, call graph analysis | | **IR Lowering** | Annotated AST | NEScript IR | Flatten expressions, expand u16 ops, desugar | -| **Optimizer** | IR | Optimized IR | Constant folding, dead code, ZP promotion, inlining | -| **Codegen** | Optimized IR | 6502 instruction list | Register allocation, instruction selection | -| **Assembler** | 6502 instructions | Byte sequences + fixups| Opcode encoding, address resolution | -| **Linker** | Bytes + assets | .nes file | Bank layout, vectors, iNES header | +| **Optimizer** | IR | Optimized IR | Constant folding, dead code, strength reduction, inlining | +| **Codegen** | Optimized IR | 6502 instruction list | Slot allocation, instruction selection | +| **Peephole** | 6502 instructions | 6502 instructions | Dead-load elimination, branch folding, INC/DEC fold | +| **Linker** | Instructions + assets | .nes file | Bank layout, vectors, iNES header | --- ## Modules +Each module has a `mod.rs` (implementation) and a co-located `tests.rs` with unit tests. + ### `lexer/` -Tokenizes NEScript source text into a stream of typed tokens with source spans. Handles decimal, hex, and binary integer literals, string literals, all keywords, and operators. +`mod.rs`, `token.rs`, `tests.rs`. Tokenizes NEScript source into a stream of typed tokens with source spans. Handles decimal/hex/binary integer literals, string literals, keywords, operators, and the raw-capture mode for `asm { ... }` bodies. ### `parser/` -Recursive descent parser that converts the token stream into an AST. Defines all AST node types (expressions, statements, declarations) in `ast.rs`. +`mod.rs`, `ast.rs`, `preprocess.rs`, `tests.rs`. Recursive descent parser that converts the token stream into an AST. `ast.rs` defines every AST node type (expressions, statements, declarations). `preprocess.rs` inlines `include "path.ne"` directives before parsing. ### `analyzer/` -Performs semantic analysis on the AST: type checking (`types.rs`), scope and symbol table management (`scope.rs`), and call graph construction with depth analysis (`call_graph.rs`). Detects recursion, type mismatches, undefined references, and call depth violations. +`mod.rs`, `tests.rs`. Performs semantic analysis on the AST: type checking, scope and symbol table management, call graph construction with depth analysis, state reachability, unused-variable detection, and dead-code-after-terminator warnings. Emits all user-facing diagnostics beyond lexer/parser syntax errors. ### `ir/` -Defines the intermediate representation and the lowering pass (`lowering.rs`) that translates the annotated AST into IR. Flattens nested expressions, expands 16-bit operations into 8-bit sequences, and resolves syntactic sugar. +`mod.rs` (types), `lowering.rs` (AST → IR), `tests.rs`. The IR is a flat, register-agnostic representation built from virtual temps and basic blocks. Lowering flattens nested expressions, expands 16-bit operations, desugars `for` into `while`, and resolves constant expressions early. ### `optimizer/` -Runs optimization passes over the IR: constant folding (`const_fold.rs`), dead code elimination (`dead_code.rs`), zero-page promotion analysis (`zp_promote.rs`), and function inlining (`inliner.rs`). +`mod.rs`, `tests.rs`. Runs passes over the IR in order: strength reduction (mul/div by powers of two, mod by powers of two, ShiftVar → ShiftLeft/Right), function inlining (trivial-function elimination), constant folding (arithmetic + comparisons + shifts), and dead code elimination. ### `codegen/` -Translates optimized IR into 6502 instructions. Includes register allocation for the A/X/Y registers (`regalloc.rs`) and instruction pattern matching for idiomatic 6502 code (`patterns.rs`). +`ir_codegen.rs`, `peephole.rs`, `mod.rs`. `ir_codegen.rs` walks the optimized IR and emits 6502 instructions; variables land in allocated addresses and IR temps land in a recycling zero-page slot pool. `peephole.rs` runs after codegen to clean up the temp-heavy output (dead-load elimination, branch folding, INC/DEC fold, copy propagation). ### `asm/` -The built-in assembler. Encodes 6502 instructions (`encode.rs`) with all addressing modes (`addressing.rs`), using a complete opcode table (`opcodes.rs` -- 56 instructions across all modes). +`mod.rs`, `opcodes.rs`, `inline_parser.rs`, `tests.rs`. The built-in assembler and the inline-asm parser. `opcodes.rs` defines the 6502 opcode table with addressing modes. `inline_parser.rs` parses the body of `asm { ... }` blocks so codegen can splice real instructions in-line. ### `linker/` -Assigns addresses to code and data segments, resolves fixups/relocations (`fixups.rs`), and handles bank allocation (`banks.rs`) for banked mappers. +`mod.rs`, `tests.rs`. Assigns addresses to code and data segments, resolves label/symbol fixups, lays out banks for banked mappers (MMC1/UxROM/MMC3), and emits the final iNES byte stream via `rom::RomBuilder`. ### `rom/` -Builds the final iNES ROM file. Generates the 16-byte iNES header (`header.rs`) and places the NMI/RESET/IRQ vector table (`vectors.rs`). +`mod.rs`, `tests.rs`. Builds the final iNES ROM file. Generates the 16-byte iNES header and places the NMI/RESET/IRQ vector table. ### `runtime/` -Contains built-in runtime code that the compiler emits into every ROM: NES hardware initialization, NMI handler generation, controller read routines, OAM DMA setup, software multiply/divide, and the frame-walking audio driver (`gen_audio_tick`, `gen_period_table`, `gen_data_block`). +`mod.rs`, `tests.rs`. Contains built-in runtime code emitted into every ROM: NES hardware init, NMI handler, controller reads, OAM DMA, software multiply/divide, the frame-walking audio driver (`gen_audio_tick`, `gen_period_table`, `gen_data_block`), and the MMC1/UxROM/MMC3 mapper init and bank-switch helpers. ### `assets/` -The asset pipeline. Converts PNG images to CHR tile data (`chr.rs`), maps RGB colors to the NES palette (`palette.rs`), resolves `sprite`/`background` declarations into tile-indexed CHR blocks (`resolve.rs`), and compiles `sfx`/`music` declarations into ROM-ready envelope and note-stream byte tables (`audio.rs`) — plus the builtin effect/track tables used as fallbacks when programs reference audio names without declaring them. - -### `debug/` -Debug instrumentation output. Generates source maps relating ROM addresses to source locations (`source_map.rs`), symbol tables compatible with Mesen (`symbols.rs`), and runtime check code for debug builds (`checks.rs`). +`mod.rs`, `chr.rs`, `palette.rs`, `resolve.rs`, `audio.rs`, `tests.rs`. The asset pipeline. `chr.rs` converts PNGs to CHR tile data. `palette.rs` maps RGB to NES palette indices. `resolve.rs` resolves `sprite` declarations into tile-indexed CHR blocks. `audio.rs` compiles `sfx`/`music` declarations into ROM-ready envelope and note-stream byte tables, plus the builtin effect/track tables used when programs reference audio names they haven't declared. ### `errors/` -Error reporting infrastructure. Defines the `Diagnostic` struct with error codes, severity levels, source spans, labels, help text, and notes (`diagnostic.rs`). Renders diagnostics with color and source context for terminal output (`render.rs`). +`mod.rs`, `diagnostic.rs`, `render.rs`. Defines the `Diagnostic` struct (error codes, severity, spans, labels, help text). Renders diagnostics with color and source context for terminal output using ariadne. --- @@ -82,7 +73,7 @@ Error reporting infrastructure. Defines the `Diagnostic` struct with error codes ### Test Organization -Tests are co-located with each module in `tests.rs` files: +Tests are co-located with each module in `tests.rs` files under `src/`: ``` src/lexer/tests.rs -- lexer unit tests @@ -90,20 +81,14 @@ src/parser/tests.rs -- parser unit tests src/analyzer/tests.rs -- semantic analysis tests src/ir/tests.rs -- IR lowering tests src/optimizer/tests.rs -- optimizer tests -src/codegen/tests.rs -- code generation tests src/asm/tests.rs -- assembler tests src/linker/tests.rs -- linker tests src/rom/tests.rs -- ROM builder tests +src/runtime/tests.rs -- runtime code emission tests src/assets/tests.rs -- asset pipeline tests ``` -Integration tests live in the `tests/` directory: - -``` -tests/integration/ -- full pipeline tests with .ne source files -tests/error_tests/ -- tests that verify specific error codes -tests/asm_tests/ -- 6502 opcode and addressing mode tests -``` +End-to-end and error-code tests live in `tests/integration_test.rs`, which compiles representative `.ne` snippets through the full pipeline and asserts on ROM/diagnostic shape. The emulator smoke test (`tests/emulator/run_examples.mjs`) runs every example through `jsnes` and byte-compares the resulting screenshot and audio hash against goldens in `tests/emulator/goldens/`. ### Running Tests @@ -117,7 +102,7 @@ cargo test --lib parser cargo test --lib analyzer # Run integration tests only -cargo test --test '*' +cargo test --test integration_test # Run a specific test by name cargo test test_name @@ -125,6 +110,4 @@ cargo test test_name ### Test Strategy -Each compiler phase is designed as a pure function, so unit tests provide isolated input and verify output without side effects. Integration tests compile complete `.ne` source files and verify the output ROM matches expected golden files in `tests/integration/expected/`. - -Error tests in `tests/error_tests/` contain intentionally broken programs and verify that the correct error code is produced (e.g., `recursion.ne` should produce `E0402`). +Each compiler phase is a pure function, so unit tests provide isolated input and verify output without side effects. Integration tests compile complete `.ne` programs and either assert on shape (length, presence of specific labels) or byte-compare output against checked-in goldens. Emulator goldens catch regressions that pass type-check but corrupt the final executable image. diff --git a/docs/future-work.md b/docs/future-work.md index ef39620..0c258dc 100644 --- a/docs/future-work.md +++ b/docs/future-work.md @@ -1,524 +1,180 @@ # Future Work -This document catalogs known gaps, incomplete features, and planned improvements -in the NEScript compiler. Items are organized by priority and area. +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. --- -## 1. IR-Based Code Generation +## Runtime palette / nametable updates -**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. +**Status.** Parsed away. Previously the compiler accepted `palette Name +{ colors: [...] }`, `background Name { chr: @chr(...) }`, `load_background +Name`, and `set_palette Name` statements, but the lowering silently dropped +them: the declarations were never resolved into CHR or palette blobs and the +statements emitted zero instructions. They were removed during cleanup to +avoid quietly misleading users. + +**What a proper implementation needs.** +- New AST declarations for palette and background/nametable data, with + analyzer validation for color indices and nametable dimensions. +- An asset pipeline pass that compiles PNG or inline byte data into a + ROM-resident blob with a known label. +- Runtime helpers that write to PPU `$2006/$2007` during vblank from a + source pointer. +- IR ops `LoadBackground(BlobId)` and `SetPalette(BlobId)` that the + codegen emits inside an NMI-safe window. +- A `--memory-map` breakdown that shows palette and nametable budgets, + since they eat into the PRG-ROM cost model that `memory_map` reports + for CHR bytes. + +Until this exists, programs should use `poke(0x2006, ...)` / `poke(0x2007, ...)` +directly inside an `on frame` handler (runs immediately after vblank) to push +palette or nametable updates. --- -## 3. Sprite Name Resolution +## User code distribution across switchable banks -**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). +**Status.** `mapper: MMC1 / UxROM / MMC3` plus top-level `bank Name { prg }` +declarations are honored by the iNES header and by the linker, which reserves +each declared bank as a 16 KB switchable slot. However, the IR codegen puts +every user function and state handler into the fixed bank at `$C000-$FFFF` — +the declared banks exist only as empty space. Programs outgrowing the fixed +16 KB have nowhere to put their code. -**What's needed**: -- Track a mapping from sprite name → CHR tile index in the linker -- When a `sprite` declaration 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_slot` counter per frame, - allocate slots 0..63 as draws are emitted, warn if >64 +**What's needed.** +- A bank-assignment step (analyzer or a new pass) that maps each user function + / state handler to a target bank, either via explicit `bank Foo { fun bar() + ... }` nesting or by greedy size-packing. +- Codegen support for emitting into non-fixed banks and for generating + cross-bank trampolines (the runtime helper scaffold already exists in + `runtime/mod.rs::gen_bank_trampoline`; it just isn't invoked). +- Linker changes so that functions in a switchable bank are found by the + JSR fix-up logic when the call crosses bank boundaries. --- -## 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 `draw` gets 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_state` zero-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 StateName` writes the new state index to `current_state` -- Generate `on_enter` call on transition, `on_exit` call 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 `Program` AST -- Span tracking: included files need their own `file_id` for 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 failure -- `debug.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`/`DebugAssert` - in the `Statement` enum 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")`, call `png_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()` in `src/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 cast -- `E0203` — invalid operation for type -- `E0301` — zero-page overflow -- `E0403` — unreachable state -- `E0505` — multiple start declarations -- `W0101` — expensive multiply/divide operation -- `W0102` — loop without break or wait_frame -- `W0103` — unused variable -- `W0104` — unreachable code after return/break/transition - -### Missing validations -- No error for assigning to a `const` -- No error for `break`/`continue` outside a loop -- No warning for variables declared but never read -- No error for `return` with 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 `notes` list 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) +## Language feature gaps (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 | +| 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. | +| **Metasprites** | Multi-tile sprite groups with relative positioning. | +| **Tilemaps** | Declarative level data with built-in collision queries. | +| **SRAM / saves** | Persistent storage declarations for battery-backed save data. | +| **NES 2.0 header** | Extended iNES header format for additional metadata. | + +### Struct / array field widths + +Struct and array element types are currently restricted to the single-byte +primitives (`u8`, `i8`, `bool`). `u16`, nested struct fields, and array fields +are rejected with `E0201`. The analyzer's field-layout machinery needs to +grow multi-byte offsets, and IR lowering needs to treat wide fields as the +existing wide-var path already does for `u16` globals. --- -## 14. Inline Assembly +## Audio pipeline -**Status**: `asm { }` blocks are lexed (`KwAsm`, `AsmBody`) but not parsed or -compiled. The lexer has raw-capture mode for asm content. +**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. -**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 +**Still TODO for richer audio.** +- Triangle / noise / DMC channels (today the driver only uses pulse 1 and + pulse 2). +- Multi-channel tracker playback (one `notes` list per channel). +- `@sfx("file.nsf")` / `@music("file.ftm")` asset directives — neither the + NSF nor the FamiTracker format is parsed yet. +- Per-note pitch changes within a sfx (currently `pitch` latches once at + trigger time). --- -## 15. Compiler Performance +## Debug instrumentation -**Status**: Compilation is fast (<100ms for all examples) but has no benchmarks. +**What ships today.** `debug.log(...)` and `debug.assert(...)` lower to $4800 +writes when `--debug` is passed, and are stripped entirely in release builds. -**What's needed**: -- `cargo bench` benchmarks for each pipeline phase -- Regression test: compilation must stay under 500ms for any reasonable project -- Profile-guided optimization of hot paths (lexer, parser) +**Not yet implemented.** +- Mesen-compatible symbol export (`.mlb` / `.sym` files) — the CLI does not + emit them, and the previous `DebugSymbols` helper was removed as dead code + during cleanup. +- Source maps relating ROM addresses to source lines — the `SourceLoc` + IR op exists but is not consumed by the linker or CLI. +- Array bounds checking in debug mode. +- Frame overrun detection (cycles-per-frame counting). +- `debug.overlay(x, y, text)` — needs the text/HUD subsystem above. --- -## 16. WASM Build Target +## Code quality / tooling -**What's needed**: -- Factor out all file I/O behind a trait (`FileSystem` / `VFS`) -- Core pipeline takes `&str` source → `Vec` ROM with no filesystem access -- Compile the compiler to WASM for a browser-based IDE -- In-browser NES emulator integration for instant preview +### Register allocator + +All IR temps currently spill to a recycled zero-page slot (`$80-$FF`). The +peephole pass mops up the most obvious waste, but a real CFG-aware allocator +that holds short-lived temps in `A`/`X`/`Y` would cut a noticeable number of +LDA/STA pairs. + +### 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. + +### `--no-opt` flag + +There is no way to disable the optimizer from the CLI. Adding one would make +optimizer-introduced bugs easier to bisect. + +### Compilation benchmarks + +Compilation is fast (<100 ms for every example today) but has no `cargo +bench` harness, so regressions would slip through. + +### 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` without touching `std::fs`. Today the +parser's preprocess pass and the asset resolver read files directly. --- -## 17. Open Design Questions +## Error message polish -From the engineering plan: +### Unused error codes -1. **Inline asm label syntax**: `.label:` (ca65 style) vs `label:` (generic)? -2. **Debug port address**: $4800 is conventional but not universal. Support - multiple debug output methods? -3. **OAM allocation strategy**: Sequential allocation vs priority-based with +`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. + +### Missing diagnostics + +- No warning for implicit-drop of a function return value (`my_fun()` at + statement position when `my_fun` returns non-void). +- `W0102` ("loop without yield") is only emitted for bare `loop`, not for + `while true` or `loop { if cond { continue } }`. +- No warning for `fast` variables that never justify the zero-page slot + (could cross-reference access counts). + +--- + +## 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. **OAM allocation strategy.** Sequential allocation vs priority-based with automatic sprite cycling for the 8-per-scanline limit? -4. **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::ShiftLeftAssign` and `AssignOp::ShiftRightAssign` variants -- 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.X` and `p2.button.X` syntax producing `Player::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`, and `gen_data_block` implement a frame-walking - pulse driver. `src/assets/audio.rs` compiles user `sfx`/`music` - declarations (and builtins referenced via `play coin` etc.) into - ROM-ready envelope and note-stream byte tables. `IrCodeGen::with_audio` - threads the compile-time trigger constants into `play`/`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_used` marker 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`, `CmpEq16` through `CmpGtEq16`. 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 emits - `CLC;ADC;ADC` for 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_irq` now - 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_reload` resets 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_counts` pre-scans each - function to count per-temp uses; `retire_op_sources` decrements - the counts after each op runs and pushes dead slots back onto - `free_slots` for later allocation. Previously, `bitwise_ops.ne` - crashed (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_dec` collapses - `LDA addr; CLC; ADC #1; STA addr` into a single `INC addr` - (and the SEC/SBC/#1 variant into `DEC 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_loads` only 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_state` in 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.X` syntax, 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 - `--debug` is set, stripped in release mode -- **--debug CLI flag wired** — threads through `IrCodeGen::with_debug` -- **IR-based codegen** — `src/codegen/ir_codegen.rs` walks `IrProgram` and - 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-ast` flag were removed once the IR pipeline was proven correct - by the jsnes emulator smoke test. -- **IR lowering bug fixes** — `ReadInput` now has a destination temp, - `ButtonRead` uses the proper input temp, logical AND/OR use a new - `emit_move` helper 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 to `IrOp::Scroll(x, y)` - which emits two PPU $2005 writes in IR codegen -- **IR debug.log / debug.assert** — new `IrOp::DebugLog(temps)` and - `IrOp::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 via `png_to_chr()`. Missing files - are silently skipped (documentation-friendly) -- **Call arity validation** — E0203 when `Statement::Call` or - `Expr::Call` has the wrong number of arguments or a mismatched - argument type (uses a `function_signatures` map) -- **Return type validation** — `return value` is 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 no `break`, `return`, `transition`, or `wait_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`, or `transition` in 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-$FF` for 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 X` token -- **`on scanline(N)` handlers** — `state { on scanline(240) { ... } }` - parses and populates `StateDecl::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_user` and a - `__ir_mmc3_reload` helper that picks the right `$C000` latch value - based on `current_state`. See `examples/scanline_split.ne` and - `examples/mmc3_per_state_split.ne`. -- **Inline assembly** — `asm { ... }` blocks. The lexer captures the - body as a raw `AsmBody` token; `src/asm/inline_parser.rs` provides 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 so `break`/`continue` work. -- **Audio subsystem** — full data-driven pulse driver with - `sfx`/`music` block 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 + 3` evaluates - at compile time and feeds through to variable initializers too. -- **`on scanline` codegen (minimal)** — MMC3 IRQ setup at startup - plus a `__irq_user` dispatcher that saves registers, ACKs via - `$E000`, dispatches on `current_state` to the right scanline - handler, and restores. `__ir_mmc3_reload` helper re-arms the - counter each frame from NMI. -- **Peephole optimizer** — `src/codegen/peephole.rs` runs 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-ir` CLI flag** — prints the lowered IR program after the - optimizer pass for debugging -- **Function-local variables** — IR codegen allocates backing - storage for `var`s 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_name` - but functions were defined as `__ir_fn_name`, and param VarIds - weren't in `var_addrs` so 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 an `asm { ... }` - block, `{name}` is replaced with the hex address of the variable - `name`. 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-map` CLI flag** — prints a human-readable variable - allocation table showing what's in ZP vs main RAM. -- **`--call-graph` CLI 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: - -1. **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. -2. **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. -3. **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). -4. **Text / HUD layer** — font sheet + layout system for scores. -5. **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. -6. **Peephole: drop LDA dead across unconditional JMPs** — after - the INC/DEC fold we sometimes leave an `LDA #1` whose value is - consumed by nothing before the next `JMP __ir_main_loop`. Local - analysis can't prove it's dead; a cross-block pass could. +4. **Error recovery granularity.** How aggressively should the parser + recover? More recovery means more errors per compile but also risks + cascading false errors. diff --git a/docs/language-guide.md b/docs/language-guide.md index 8dcb648..583afeb 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -641,22 +641,6 @@ Set the PPU scroll position: scroll(scroll_x, scroll_y) ``` -### Load Background - -Load a background nametable: - -``` -load_background TitleBG -``` - -### Set Palette - -Apply a palette: - -``` -set_palette GamePalette -``` - ### Function Calls as Statements ``` @@ -680,29 +664,6 @@ sprite Coin { } ``` -### Palette Declarations - -Define color palettes using NES PPU color indices (`$00`-`$3F`): - -``` -palette GamePalette { - colors: [0x0F, 0x00, 0x10, 0x30, - 0x0F, 0x07, 0x17, 0x27, - 0x0F, 0x09, 0x19, 0x29, - 0x0F, 0x01, 0x11, 0x21] -} -``` - -Each group of 4 values is a sub-palette. The first color is typically `0x0F` (black) as the shared background color. - -### Background Declarations - -``` -background TitleBG { - chr: @chr("assets/title_screen.png") -} -``` - ### Asset Sources Three ways to provide asset data: @@ -964,7 +925,6 @@ reference NEScript variables. | Code | Description | |--------|----------------------------| | E0201 | Type mismatch | -| E0202 | Invalid cast | | E0203 | Invalid operation for type | ### Memory Errors (E03xx) @@ -979,7 +939,6 @@ reference NEScript variables. |--------|----------------------------| | E0401 | Call depth exceeded | | E0402 | Recursion detected | -| E0403 | Unreachable state | | E0404 | Transition to undefined state | ### Declaration Errors (E05xx) @@ -999,7 +958,7 @@ reference NEScript variables. | W0101 | Expensive multiply/divide operation | | W0102 | Loop without break or wait_frame | | W0103 | Unused variable | -| W0104 | Unreachable code | +| W0104 | Unreachable code (after return/break/transition, or state unreachable from start) | ### Example Error Output diff --git a/examples/README.md b/examples/README.md index aab1bed..29e98ca 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,7 +24,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `coin_cavern.ne` | states, functions, constants | 3-state game with gravity and coin collection | | `arrays_and_functions.ne` | arrays, functions, while | Enemy array with collision detection | | `state_machine.ne` | on enter/exit, transitions | Multi-state flow with timers | -| `sprites_and_palettes.ne` | sprites, palettes, scroll, cast | Inline CHR data, palette switching, type casting | +| `sprites_and_palettes.ne` | sprites, scroll, cast | Inline CHR data, PPU scroll writes, type casting | | `mmc1_banked.ne` | MMC1, banks, multiply | Banked mapper with software multiply | ## Emulator Controls diff --git a/examples/sprites_and_palettes.ne b/examples/sprites_and_palettes.ne index dc3349c..c15e6b4 100644 --- a/examples/sprites_and_palettes.ne +++ b/examples/sprites_and_palettes.ne @@ -1,7 +1,7 @@ -// Sprites and Palettes — demonstrates M3 asset features. +// Sprites and Scroll — demonstrates M3/M4 asset features. // -// Shows: sprite declarations with inline CHR data, -// palette declarations, set_palette, type casting, scroll. +// Shows: sprite declarations with inline CHR data, type casting, +// PPU scroll writes. // // Build: cargo run -- build examples/sprites_and_palettes.ne @@ -22,19 +22,9 @@ sprite Heart { 0x66, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00] } -// Custom color palette -palette Warm { - colors: [0x0F, 0x06, 0x16, 0x26] -} - -palette Cool { - colors: [0x0F, 0x01, 0x11, 0x21] -} - var px: u8 = 128 var py: u8 = 120 var scroll_x: u8 = 0 -var use_warm: u8 = 1 on frame { // Movement @@ -47,17 +37,6 @@ on frame { scroll_x += 1 scroll(scroll_x, 0) - // Toggle palette with A button - if button.a { - if use_warm == 1 { - set_palette Cool - use_warm = 0 - } else { - set_palette Warm - use_warm = 1 - } - } - // Type cast demo var wide: u16 = px as u16 diff --git a/spec.md b/spec.md index ca33dde..c0c0b6a 100644 --- a/spec.md +++ b/spec.md @@ -59,16 +59,17 @@ The following identifiers are reserved: ``` game state on fun var const -if else while break continue return +if else while for in match +break continue return true false not and or fast slow include start transition -sprite background palette sfx music +sprite sfx music draw play stop_music start_music -load_background set_palette asm raw bank loop wait_frame u8 i8 u16 bool -debug +enum struct +debug as ``` ### 2.4 Identifiers @@ -497,20 +498,7 @@ The compiler auto-generates the controller read routine and previous-frame track ### 10.1 Palettes -The NES has 4 background palettes and 4 sprite palettes, each containing 4 colors selected from the NES's 64-color master palette. - -``` -palette my_palette { - [0x0F, 0x00, 0x10, 0x30] // sub-palette 0 - [0x0F, 0x07, 0x17, 0x27] // sub-palette 1 - [0x0F, 0x09, 0x19, 0x29] // sub-palette 2 - [0x0F, 0x01, 0x11, 0x21] // sub-palette 3 -} - -set_palette my_palette // applies during vblank -``` - -Color values are NES PPU color indices ($00–$3F). The first color in each sub-palette is typically $0F (black) as it serves as the shared background color. +The NES has 4 background palettes and 4 sprite palettes, each containing 4 colors selected from the NES's 64-color master palette. First-class `palette` declarations and `set_palette` / `load_background` statements are not yet in the language — see `docs/future-work.md`. Until then, push palette and nametable bytes directly via `poke(0x2006, ...)` / `poke(0x2007, ...)` inside an `on frame` handler (immediately after vblank). ### 10.2 Sprites @@ -537,13 +525,7 @@ The `draw` statement writes to the OAM shadow buffer. The compiler manages OAM s ### 10.3 Backgrounds -``` -background BGName { - data: @nametable("file.png") // full-screen image → tile + nametable data -} - -load_background BGName // writes nametable during vblank -``` +First-class `background` declarations and `load_background` are not yet in the language. See `docs/future-work.md` for the roadmap; for now, use `poke` inside a frame handler to push nametable bytes directly to PPU `$2006/$2007`. ### 10.4 Scrolling @@ -677,15 +659,7 @@ debug.log("Coin collected! Score: ", score) In debug mode, `debug.log` writes to the emulator's debug console (using a mapper-specific debug register or a standardized debug output port at $4800). In release mode, all `debug.*` statements are stripped entirely — zero bytes, zero cycles. -### 14.2 Debug Overlay - -``` -debug.overlay("PX:", px, " VY:", vy) -``` - -Renders debug text on-screen using a reserved section of the nametable. Stripped in release mode. - -### 14.3 Debug Assertions +### 14.2 Debug Assertions ``` debug.assert(lives > 0, "Lives should never be negative here") @@ -694,18 +668,17 @@ debug.assert(px < 255, "Player out of bounds") In debug mode, a failed assertion halts execution and outputs the message. In release mode, stripped entirely. -### 14.4 Runtime Checks (Debug Only) +### 14.3 Runtime Checks (future) -The following checks are inserted automatically in debug mode: +The following checks are planned for debug mode but are not yet emitted (see `docs/future-work.md`): -- **Array bounds checking**: Indexed access emits a bounds test before the load/store. -- **Overflow warnings**: Arithmetic operations test for carry/overflow and log a warning. -- **Stack depth monitoring**: Each function entry checks remaining stack space. -- **Frame overrun detection**: A timer checks whether the frame handler took longer than one vblank period (~29,780 CPU cycles) and logs a warning. +- **Array bounds checking**: Indexed access should emit a bounds test before the load/store. +- **Stack depth monitoring**: Each function entry should check remaining stack space. +- **Frame overrun detection**: A timer should check whether the frame handler took longer than one vblank period (~29,780 CPU cycles) and log a warning. -### 14.5 Source Maps +### 14.4 Source Maps (future) -The compiler outputs a `.dbg` source map file relating ROM addresses to NEScript source locations (file, line, column). Debug-aware emulators can use this to set breakpoints on NEScript lines and display NEScript variable names in watch windows. +A debug-symbol output (`.dbg` / `.mlb` / `.sym`) relating ROM addresses to NEScript source locations is planned but not yet emitted. See `docs/future-work.md`. --- @@ -874,8 +847,7 @@ game_prop = IDENT ":" ( IDENT | INTEGER ) ; include = "include" STRING ; top_level_decl = var_decl | const_decl | fun_decl | state_decl - | sprite_decl | background_decl | palette_decl - | sfx_decl | music_decl | bank_decl ; + | sprite_decl | sfx_decl | music_decl | bank_decl ; var_decl = ["fast" | "slow"] "var" IDENT ":" type ["=" expr] ; const_decl = "const" IDENT ":" type "=" expr ; @@ -894,14 +866,11 @@ event_kind = "enter" | "exit" | "frame" | "scanline" "(" INTEGER ")" ; sprite_decl = "sprite" IDENT "{" { sprite_prop } "}" ; sprite_prop = IDENT ":" expr ; -background_decl = "background" IDENT "{" { bg_prop } "}" ; -bg_prop = IDENT ":" expr ; - -palette_decl = "palette" IDENT "{" { palette_row } "}" ; -palette_row = "[" INTEGER "," INTEGER "," INTEGER "," INTEGER "]" ; - -sfx_decl = "sfx" IDENT "{" "data" ":" asset_ref "}" ; -music_decl = "music" IDENT "{" "data" ":" asset_ref "}" ; +sfx_decl = "sfx" IDENT "{" sfx_prop { sfx_prop } "}" ; +sfx_prop = ("duty" ":" INTEGER | "pitch" ":" "[" int_list "]" | "volume" ":" "[" int_list "]") ; +music_decl = "music" IDENT "{" music_prop { music_prop } "}" ; +music_prop = ("duty" ":" INTEGER | "volume" ":" INTEGER | "repeat" ":" BOOL | "notes" ":" "[" int_list "]") ; +int_list = INTEGER { "," INTEGER } ; bank_decl = "bank" INTEGER "{" { top_level_decl } "}" ; @@ -909,13 +878,14 @@ asset_ref = "@" IDENT "(" STRING { "," IDENT ":" expr } ")" ; block = "{" { statement } "}" ; statement = var_decl | const_decl | assign_stmt | if_stmt | while_stmt - | loop_stmt | return_stmt | break_stmt | continue_stmt + | for_stmt | loop_stmt | return_stmt | break_stmt | continue_stmt | draw_stmt | play_stmt | transition_stmt | fun_call | asm_block | debug_stmt | music_stmt | scroll_stmt - | load_bg_stmt | set_palette_stmt | wait_frame_stmt ; + | wait_frame_stmt ; if_stmt = "if" expr block { "else" "if" expr block } [ "else" block ] ; while_stmt = "while" expr block ; +for_stmt = "for" IDENT "in" expr ".." expr block ; loop_stmt = "loop" block ; return_stmt = "return" [expr] ; break_stmt = "break" ; @@ -928,12 +898,9 @@ play_stmt = "play" IDENT ; music_stmt = ("start_music" | "stop_music") [IDENT] ; transition_stmt = "transition" IDENT ; scroll_stmt = "scroll" "(" expr "," expr ")" ; -load_bg_stmt = "load_background" IDENT ; -set_palette_stmt= "set_palette" IDENT ; wait_frame_stmt = "wait_frame" "(" ")" ; -debug_stmt = "debug" "." ( "log" | "overlay" | "assert" ) - "(" expr { "," expr } ")" ; +debug_stmt = "debug" "." ( "log" | "assert" ) "(" expr { "," expr } ")" ; asm_block = "asm" "{" ASM_BODY "}" ; | "raw" "asm" "{" ASM_BODY "}" ; diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index d998f21..de612ee 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -1060,9 +1060,7 @@ impl Analyzer { )); } } - Statement::WaitFrame(_) - | Statement::LoadBackground(_, _) - | Statement::SetPalette(_, _) => {} + Statement::WaitFrame(_) => {} Statement::DebugLog(args, _) => { for arg in args { self.walk_expr_reads(arg); @@ -1365,8 +1363,6 @@ fn collect_calls_stmt(stmt: &Statement, calls: &mut Vec) { | Statement::WaitFrame(_) | Statement::Break(_) | Statement::Continue(_) - | Statement::LoadBackground(_, _) - | Statement::SetPalette(_, _) | Statement::InlineAsm(_, _) | Statement::RawAsm(_, _) | Statement::Play(_, _) diff --git a/src/assets/audio.rs b/src/assets/audio.rs index 6eda238..299c190 100644 --- a/src/assets/audio.rs +++ b/src/assets/audio.rs @@ -489,8 +489,6 @@ mod tests { functions: Vec::new(), states: Vec::new(), sprites: Vec::new(), - palettes: Vec::new(), - backgrounds: Vec::new(), sfx: Vec::new(), music: Vec::new(), banks: Vec::new(), diff --git a/src/assets/resolve.rs b/src/assets/resolve.rs index b34cefe..fe93145 100644 --- a/src/assets/resolve.rs +++ b/src/assets/resolve.rs @@ -88,8 +88,6 @@ mod tests { functions: Vec::new(), states: Vec::new(), sprites: vec![sprite], - palettes: Vec::new(), - backgrounds: Vec::new(), sfx: Vec::new(), music: Vec::new(), banks: Vec::new(), diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 71dfba3..2021672 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -290,6 +290,35 @@ impl<'a> IrCodeGen<'a> { self.emit(STA, AM::ZeroPage(addr)); } + /// Emit a runtime-variable shift loop: loads `src` into A, then + /// `amt` iterations of `shift_op` (`ASL` / `LSR`), storing into + /// `dest`. An iteration count of zero is handled by a leading + /// BEQ over the loop so the source value is preserved. + fn gen_shift_var( + &mut self, + dest: IrTemp, + src: IrTemp, + amt: IrTemp, + shift_op: crate::asm::Opcode, + ) { + let suffix = self.instructions.len(); + let loop_label = format!("__ir_shift_loop_{suffix}"); + let done_label = format!("__ir_shift_done_{suffix}"); + let amt_addr = self.temp_addr(amt); + self.emit(LDX, AM::ZeroPage(amt_addr)); + self.load_temp(src); + // Skip straight to the store if the count is zero — saves an + // iteration and is required because the loop decrements + // before checking. + self.emit(BEQ, AM::LabelRelative(done_label.clone())); + self.emit_label(&loop_label); + self.emit(shift_op, AM::Accumulator); + self.emit(DEX, AM::Implied); + self.emit(BNE, AM::LabelRelative(loop_label)); + self.emit_label(&done_label); + self.store_temp(dest); + } + /// Generate instructions for an entire IR program. /// /// Layout: @@ -607,6 +636,34 @@ impl<'a> IrCodeGen<'a> { } self.store_temp(*d); } + IrOp::ShiftLeftVar(d, a, amt) => self.gen_shift_var(*d, *a, *amt, ASL), + IrOp::ShiftRightVar(d, a, amt) => self.gen_shift_var(*d, *a, *amt, LSR), + IrOp::Div(d, a, b) => { + // Software divide: dividend in A, divisor in $02. + // `__divide` returns quotient in A and leaves + // remainder in ZP $03. + self.load_temp(*a); + self.emit(PHA, AM::Implied); + let b_addr = self.temp_addr(*b); + self.emit(LDA, AM::ZeroPage(b_addr)); + self.emit(STA, AM::ZeroPage(0x02)); + self.emit(PLA, AM::Implied); + self.emit(JSR, AM::Label("__divide".into())); + self.store_temp(*d); + } + IrOp::Mod(d, a, b) => { + // Modulo reuses __divide and reads the remainder out + // of ZP $03 afterwards. + self.load_temp(*a); + self.emit(PHA, AM::Implied); + let b_addr = self.temp_addr(*b); + self.emit(LDA, AM::ZeroPage(b_addr)); + self.emit(STA, AM::ZeroPage(0x02)); + self.emit(PLA, AM::Implied); + self.emit(JSR, AM::Label("__divide".into())); + self.emit(LDA, AM::ZeroPage(0x03)); + self.store_temp(*d); + } IrOp::Negate(d, src) => { self.load_temp(*src); self.emit(EOR, AM::Immediate(0xFF)); @@ -1430,9 +1487,13 @@ fn op_source_temps(op: &IrOp) -> Vec { IrOp::Add(_, a, b) | IrOp::Sub(_, a, b) | IrOp::Mul(_, a, b) + | IrOp::Div(_, a, b) + | IrOp::Mod(_, a, b) | IrOp::And(_, a, b) | IrOp::Or(_, a, b) | IrOp::Xor(_, a, b) + | IrOp::ShiftLeftVar(_, a, b) + | IrOp::ShiftRightVar(_, a, b) | IrOp::CmpEq(_, a, b) | IrOp::CmpNe(_, a, b) | IrOp::CmpLt(_, a, b) diff --git a/src/debug/mod.rs b/src/debug/mod.rs deleted file mode 100644 index 625482c..0000000 --- a/src/debug/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod source_map; -#[cfg(test)] -mod tests; - -pub use source_map::{DebugSymbols, SourceMap, SourceMapping}; diff --git a/src/debug/source_map.rs b/src/debug/source_map.rs deleted file mode 100644 index 9a89622..0000000 --- a/src/debug/source_map.rs +++ /dev/null @@ -1,89 +0,0 @@ -use std::collections::HashMap; -use std::fmt::Write; - -use crate::analyzer::VarAllocation; -use crate::lexer::Span; - -/// A mapping from ROM address to source location. -#[derive(Debug, Clone)] -pub struct SourceMapping { - pub rom_address: u16, - pub span: Span, - pub label: Option, -} - -/// Source map for debug symbol output. -/// Maps ROM addresses back to `NEScript` source locations. -#[derive(Debug, Clone, Default)] -pub struct SourceMap { - pub mappings: Vec, -} - -impl SourceMap { - pub fn new() -> Self { - Self::default() - } - - pub fn add(&mut self, rom_address: u16, span: Span, label: Option) { - self.mappings.push(SourceMapping { - rom_address, - span, - label, - }); - } - - /// Sort mappings by ROM address. - pub fn finalize(&mut self) { - self.mappings.sort_by_key(|m| m.rom_address); - } -} - -/// Debug symbols for a compiled ROM. -/// Compatible with Mesen's .dbg format. -#[derive(Debug, Clone)] -pub struct DebugSymbols { - pub source_map: SourceMap, - pub symbols: HashMap, - pub source_file: String, -} - -impl DebugSymbols { - pub fn new(source_file: &str) -> Self { - Self { - source_map: SourceMap::new(), - symbols: HashMap::new(), - source_file: source_file.to_string(), - } - } - - /// Add variable symbols from analysis. - pub fn add_variables(&mut self, allocations: &[VarAllocation]) { - for alloc in allocations { - self.symbols.insert(alloc.name.clone(), alloc.address); - } - } - - /// Generate Mesen-compatible .mlb (label) file content. - pub fn to_mesen_labels(&self) -> String { - let mut output = String::new(); - for (name, &addr) in &self.symbols { - // Mesen label format: P:ADDR:NAME (P = PRG ROM) - let _ = writeln!(output, "P:{addr:04X}:{name}"); - } - // Sort for deterministic output - let mut lines: Vec<&str> = output.lines().collect(); - lines.sort_unstable(); - lines.join("\n") + "\n" - } - - /// Generate a simple .sym (symbol table) file. - pub fn to_sym_file(&self) -> String { - let mut output = String::from("; NEScript debug symbols\n"); - let mut entries: Vec<_> = self.symbols.iter().collect(); - entries.sort_by_key(|(_, &addr)| addr); - for (name, &addr) in &entries { - let _ = writeln!(output, "{addr:04X} {name}"); - } - output - } -} diff --git a/src/debug/tests.rs b/src/debug/tests.rs deleted file mode 100644 index eaeb757..0000000 --- a/src/debug/tests.rs +++ /dev/null @@ -1,66 +0,0 @@ -use super::*; -use crate::lexer::Span; - -#[test] -fn source_map_add_and_finalize() { - let mut sm = SourceMap::new(); - sm.add(0xC010, Span::new(0, 10, 20), Some("main_loop".into())); - sm.add(0xC000, Span::new(0, 0, 5), Some("reset".into())); - sm.finalize(); - - // Should be sorted by ROM address - assert_eq!(sm.mappings[0].rom_address, 0xC000); - assert_eq!(sm.mappings[1].rom_address, 0xC010); -} - -#[test] -fn debug_symbols_new() { - let ds = DebugSymbols::new("test.ne"); - assert_eq!(ds.source_file, "test.ne"); - assert!(ds.symbols.is_empty()); -} - -#[test] -fn debug_symbols_add_variables() { - use crate::analyzer::VarAllocation; - - let mut ds = DebugSymbols::new("test.ne"); - ds.add_variables(&[ - VarAllocation { - name: "px".into(), - address: 0x10, - size: 1, - }, - VarAllocation { - name: "py".into(), - address: 0x11, - size: 1, - }, - ]); - assert_eq!(ds.symbols["px"], 0x10); - assert_eq!(ds.symbols["py"], 0x11); -} - -#[test] -fn mesen_labels_format() { - let mut ds = DebugSymbols::new("test.ne"); - ds.symbols.insert("player_x".into(), 0x0010); - ds.symbols.insert("score".into(), 0x0012); - - let labels = ds.to_mesen_labels(); - assert!(labels.contains("P:0010:player_x")); - assert!(labels.contains("P:0012:score")); -} - -#[test] -fn sym_file_format() { - let mut ds = DebugSymbols::new("test.ne"); - ds.symbols.insert("px".into(), 0x10); - ds.symbols.insert("py".into(), 0x11); - - let sym = ds.to_sym_file(); - assert!(sym.contains("0010 px")); - assert!(sym.contains("0011 py")); - // Should have header comment - assert!(sym.starts_with("; NEScript")); -} diff --git a/src/errors/diagnostic.rs b/src/errors/diagnostic.rs index 665f5dd..d4fae9f 100644 --- a/src/errors/diagnostic.rs +++ b/src/errors/diagnostic.rs @@ -5,8 +5,6 @@ use std::fmt; pub enum Level { Error, Warning, - #[allow(dead_code)] - Info, } /// Error codes organized by compiler phase. @@ -19,8 +17,6 @@ pub enum ErrorCode { // E02xx: Type errors E0201, // type mismatch - #[allow(dead_code)] - E0202, // invalid cast E0203, // invalid operation for type // E03xx: Memory errors @@ -29,8 +25,6 @@ pub enum ErrorCode { // E04xx: Control flow errors E0401, // call depth exceeded E0402, // recursion detected - #[allow(dead_code)] - E0403, // unreachable state E0404, // transition to undefined state // E05xx: Declaration errors @@ -44,7 +38,7 @@ pub enum ErrorCode { W0101, // expensive multiply/divide operation W0102, // loop without break or wait_frame W0103, // unused variable - W0104, // unreachable state + W0104, // unreachable code after terminator, or unreachable state } impl fmt::Display for ErrorCode { @@ -54,12 +48,10 @@ impl fmt::Display for ErrorCode { Self::E0102 => "E0102", Self::E0103 => "E0103", Self::E0201 => "E0201", - Self::E0202 => "E0202", Self::E0203 => "E0203", Self::E0301 => "E0301", Self::E0401 => "E0401", Self::E0402 => "E0402", - Self::E0403 => "E0403", Self::E0404 => "E0404", Self::E0501 => "E0501", Self::E0502 => "E0502", @@ -152,7 +144,6 @@ impl fmt::Display for Diagnostic { let level = match self.level { Level::Error => "error", Level::Warning => "warning", - Level::Info => "info", }; write!(f, "{level}[{}]: {}", self.code, self.message) } diff --git a/src/errors/render.rs b/src/errors/render.rs index 62fc40c..aee154e 100644 --- a/src/errors/render.rs +++ b/src/errors/render.rs @@ -9,7 +9,6 @@ pub fn render_diagnostics(source: &str, filename: &str, diagnostics: &[Diagnosti let kind = match diag.level { Level::Error => ReportKind::Error, Level::Warning => ReportKind::Warning, - Level::Info => ReportKind::Advice, }; let mut report = Report::build(kind, filename, diag.span.start as usize) diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 0b78a2f..1a347e0 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -525,9 +525,6 @@ impl LoweringContext { let y = self.lower_expr(y_expr); self.emit(IrOp::Scroll(x, y)); } - Statement::LoadBackground(_, _) | Statement::SetPalette(_, _) => { - // TODO: implement in asset pipeline - } Statement::DebugLog(args, _) => { let temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect(); self.emit(IrOp::DebugLog(temps)); @@ -634,16 +631,7 @@ impl LoweringContext { self.emit(IrOp::StoreVarHi(var_id, d_hi)); } } else { - let ir_op = match op { - AssignOp::PlusAssign => IrOp::Add(result, current, rhs), - AssignOp::MinusAssign => IrOp::Sub(result, current, rhs), - AssignOp::AmpAssign => IrOp::And(result, current, rhs), - AssignOp::PipeAssign => IrOp::Or(result, current, rhs), - AssignOp::CaretAssign => IrOp::Xor(result, current, rhs), - AssignOp::ShiftLeftAssign => IrOp::ShiftLeft(result, current, 1), - AssignOp::ShiftRightAssign => IrOp::ShiftRight(result, current, 1), - AssignOp::Assign => unreachable!(), - }; + let ir_op = compound_assign_op(op, result, current, rhs, expr, self); self.emit(ir_op); self.emit(IrOp::StoreVar(var_id, result)); if dest_is_u16 { @@ -667,16 +655,7 @@ impl LoweringContext { let current = self.fresh_temp(); self.emit(IrOp::ArrayLoad(current, var_id, idx)); let result = self.fresh_temp(); - let ir_op = match op { - AssignOp::PlusAssign => IrOp::Add(result, current, val), - AssignOp::MinusAssign => IrOp::Sub(result, current, val), - AssignOp::AmpAssign => IrOp::And(result, current, val), - AssignOp::PipeAssign => IrOp::Or(result, current, val), - AssignOp::CaretAssign => IrOp::Xor(result, current, val), - AssignOp::ShiftLeftAssign => IrOp::ShiftLeft(result, current, 1), - AssignOp::ShiftRightAssign => IrOp::ShiftRight(result, current, 1), - AssignOp::Assign => unreachable!(), - }; + let ir_op = compound_assign_op(op, result, current, val, expr, self); self.emit(ir_op); self.emit(IrOp::ArrayStore(var_id, idx, result)); } @@ -698,16 +677,7 @@ impl LoweringContext { self.emit(IrOp::LoadVar(current, var_id)); let rhs = self.lower_expr(expr); let result = self.fresh_temp(); - let ir_op = match op { - AssignOp::PlusAssign => IrOp::Add(result, current, rhs), - AssignOp::MinusAssign => IrOp::Sub(result, current, rhs), - AssignOp::AmpAssign => IrOp::And(result, current, rhs), - AssignOp::PipeAssign => IrOp::Or(result, current, rhs), - AssignOp::CaretAssign => IrOp::Xor(result, current, rhs), - AssignOp::ShiftLeftAssign => IrOp::ShiftLeft(result, current, 1), - AssignOp::ShiftRightAssign => IrOp::ShiftRight(result, current, 1), - AssignOp::Assign => unreachable!(), - }; + let ir_op = compound_assign_op(op, result, current, rhs, expr, self); self.emit(ir_op); self.emit(IrOp::StoreVar(var_id, result)); } @@ -1036,6 +1006,27 @@ impl LoweringContext { _ => {} } + // Shift operators with a compile-time-constant RHS take a + // specialized path that bakes the count into the IR op. This + // also covers the common `x << 1` / `x >> 2` case where the + // RHS is a literal in the source. + if matches!(op, BinOp::ShiftLeft | BinOp::ShiftRight) { + if let Some(count) = self.eval_const(right) { + let l = self.lower_expr(left); + let t = self.fresh_temp(); + // Shifting by ≥ 8 zeroes an 8-bit value; clamp so the + // codegen doesn't emit an absurd number of ASL/LSR. + let count = count.min(8) as u8; + let ir_op = if op == BinOp::ShiftLeft { + IrOp::ShiftLeft(t, l, count) + } else { + IrOp::ShiftRight(t, l, count) + }; + self.emit(ir_op); + return t; + } + } + let l = self.lower_expr(left); let r = self.lower_expr(right); let wide = self.is_wide(l) || self.is_wide(r); @@ -1159,12 +1150,10 @@ impl LoweringContext { BinOp::Gt => self.emit(IrOp::CmpGt(t, l, r)), BinOp::LtEq => self.emit(IrOp::CmpLtEq(t, l, r)), BinOp::GtEq => self.emit(IrOp::CmpGtEq(t, l, r)), - BinOp::ShiftLeft => self.emit(IrOp::ShiftLeft(t, l, 1)), // TODO: dynamic shift - BinOp::ShiftRight => self.emit(IrOp::ShiftRight(t, l, 1)), - BinOp::Div | BinOp::Mod => { - // Software div/mod — emit as a call to runtime for now - self.emit(IrOp::LoadImm(t, 0)); - } + BinOp::ShiftLeft => self.emit(IrOp::ShiftLeftVar(t, l, r)), + BinOp::ShiftRight => self.emit(IrOp::ShiftRightVar(t, l, r)), + BinOp::Div => self.emit(IrOp::Div(t, l, r)), + BinOp::Mod => self.emit(IrOp::Mod(t, l, r)), BinOp::And | BinOp::Or => unreachable!("handled above"), } @@ -1263,3 +1252,40 @@ fn button_mask(button: &str) -> u8 { _ => 0x00, } } + +/// Build the IR op for a compound-assignment `lhs OP= rhs`. The +/// `rhs_expr` is consulted for shift counts so `x <<= 3` becomes +/// `ShiftLeft(result, current, 3)` rather than a runtime shift. All +/// other operators just map to their 3-address form over the already- +/// lowered temps. +fn compound_assign_op( + op: AssignOp, + result: IrTemp, + current: IrTemp, + rhs: IrTemp, + rhs_expr: &Expr, + ctx: &LoweringContext, +) -> IrOp { + match op { + AssignOp::PlusAssign => IrOp::Add(result, current, rhs), + AssignOp::MinusAssign => IrOp::Sub(result, current, rhs), + AssignOp::AmpAssign => IrOp::And(result, current, rhs), + AssignOp::PipeAssign => IrOp::Or(result, current, rhs), + AssignOp::CaretAssign => IrOp::Xor(result, current, rhs), + AssignOp::ShiftLeftAssign => { + if let Some(n) = ctx.eval_const(rhs_expr) { + IrOp::ShiftLeft(result, current, n.min(8) as u8) + } else { + IrOp::ShiftLeftVar(result, current, rhs) + } + } + AssignOp::ShiftRightAssign => { + if let Some(n) = ctx.eval_const(rhs_expr) { + IrOp::ShiftRight(result, current, n.min(8) as u8) + } else { + IrOp::ShiftRightVar(result, current, rhs) + } + } + AssignOp::Assign => unreachable!(), + } +} diff --git a/src/ir/mod.rs b/src/ir/mod.rs index 81b4dff..ee161f0 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -108,8 +108,25 @@ pub enum IrOp { And(IrTemp, IrTemp, IrTemp), Or(IrTemp, IrTemp, IrTemp), Xor(IrTemp, IrTemp, IrTemp), + /// Shift `src` left by a compile-time-known count. Lowering + /// extracts the count from constant RHS expressions; non-constant + /// shifts lower to [`IrOp::ShiftLeftVar`]. ShiftLeft(IrTemp, IrTemp, u8), + /// Shift `src` right by a compile-time-known count. ShiftRight(IrTemp, IrTemp, u8), + /// Shift `src` left by a runtime-variable count held in `amt`. + /// Codegen emits a short loop. Never produced by the lowering + /// when the RHS constant-folds; the optimizer may also turn this + /// back into [`IrOp::ShiftLeft`] once `amt` is known. + ShiftLeftVar(IrTemp, IrTemp, IrTemp), + /// Shift `src` right by a runtime-variable count held in `amt`. + ShiftRightVar(IrTemp, IrTemp, IrTemp), + /// Software 8/8 divide: `dest = a / b`. Lowered to a `__divide` + /// call in codegen; the strength reducer folds constant divisors + /// into shifts / AND masks where possible before this runs. + Div(IrTemp, IrTemp, IrTemp), + /// Software 8/8 modulo: `dest = a % b`. + Mod(IrTemp, IrTemp, IrTemp), Negate(IrTemp, IrTemp), Complement(IrTemp, IrTemp), diff --git a/src/ir/tests.rs b/src/ir/tests.rs index 1e5b87a..ebdfdb7 100644 --- a/src/ir/tests.rs +++ b/src/ir/tests.rs @@ -377,3 +377,137 @@ fn for_loop_counter_is_registered_as_handler_local() { frame_fn.locals ); } + +// Regression tests: shift / div / mod used to miscompile silently. +// `x << n` with a literal `n` always emitted ShiftLeft(..., 1) and +// `x / n` / `x % n` always emitted LoadImm(..., 0). These tests +// anchor the fixes from the code-review cleanup pass. + +#[test] +fn lower_shift_left_with_literal_count_uses_that_count() { + let ir = lower_ok( + r#" + game "Test" { mapper: NROM } + var x: u8 = 1 + on frame { x = x << 3 } + start Main + "#, + ); + let frame_fn = ir + .functions + .iter() + .find(|f| f.name.contains("frame")) + .expect("frame handler should exist"); + let has_shift3 = frame_fn + .blocks + .iter() + .flat_map(|b| &b.ops) + .any(|op| matches!(op, IrOp::ShiftLeft(_, _, 3))); + assert!( + has_shift3, + "expected ShiftLeft with count=3, got ops: {:?}", + frame_fn + .blocks + .iter() + .flat_map(|b| &b.ops) + .collect::>() + ); +} + +#[test] +fn lower_shift_right_with_variable_count_uses_runtime_variant() { + let ir = lower_ok( + r#" + game "Test" { mapper: NROM } + var x: u8 = 128 + var n: u8 = 2 + on frame { x = x >> n } + start Main + "#, + ); + let frame_fn = ir + .functions + .iter() + .find(|f| f.name.contains("frame")) + .expect("frame handler should exist"); + let has_shift_var = frame_fn + .blocks + .iter() + .flat_map(|b| &b.ops) + .any(|op| matches!(op, IrOp::ShiftRightVar(..))); + assert!( + has_shift_var, + "expected ShiftRightVar for runtime shift amount, got ops: {:?}", + frame_fn + .blocks + .iter() + .flat_map(|b| &b.ops) + .collect::>() + ); +} + +#[test] +fn lower_divide_emits_div_op_not_load_imm_zero() { + let ir = lower_ok( + r#" + game "Test" { mapper: NROM } + var x: u8 = 100 + var d: u8 = 7 + var q: u8 = 0 + on frame { q = x / d } + start Main + "#, + ); + let frame_fn = ir + .functions + .iter() + .find(|f| f.name.contains("frame")) + .expect("frame handler should exist"); + let has_div = frame_fn + .blocks + .iter() + .flat_map(|b| &b.ops) + .any(|op| matches!(op, IrOp::Div(..))); + assert!( + has_div, + "expected IrOp::Div for `q = x / d`, got ops: {:?}", + frame_fn + .blocks + .iter() + .flat_map(|b| &b.ops) + .collect::>() + ); +} + +#[test] +fn lower_modulo_emits_mod_op_not_load_imm_zero() { + let ir = lower_ok( + r#" + game "Test" { mapper: NROM } + var x: u8 = 17 + var d: u8 = 5 + var r: u8 = 0 + on frame { r = x % d } + start Main + "#, + ); + let frame_fn = ir + .functions + .iter() + .find(|f| f.name.contains("frame")) + .expect("frame handler should exist"); + let has_mod = frame_fn + .blocks + .iter() + .flat_map(|b| &b.ops) + .any(|op| matches!(op, IrOp::Mod(..))); + assert!( + has_mod, + "expected IrOp::Mod for `r = x % d`, got ops: {:?}", + frame_fn + .blocks + .iter() + .flat_map(|b| &b.ops) + .collect::>() + ); +} diff --git a/src/lexer/mod.rs b/src/lexer/mod.rs index 1955db1..d6ab728 100644 --- a/src/lexer/mod.rs +++ b/src/lexer/mod.rs @@ -462,16 +462,12 @@ impl<'a> Lexer<'a> { "start" => TokenKind::KwStart, "transition" => TokenKind::KwTransition, "sprite" => TokenKind::KwSprite, - "background" => TokenKind::KwBackground, - "palette" => TokenKind::KwPalette, "sfx" => TokenKind::KwSfx, "music" => TokenKind::KwMusic, "draw" => TokenKind::KwDraw, "play" => TokenKind::KwPlay, "stop_music" => TokenKind::KwStopMusic, "start_music" => TokenKind::KwStartMusic, - "load_background" => TokenKind::KwLoadBackground, - "set_palette" => TokenKind::KwSetPalette, "scroll" => TokenKind::KwScroll, "asm" => { self.asm_body_pending = true; diff --git a/src/lexer/tests.rs b/src/lexer/tests.rs index a436b1f..0e5cab6 100644 --- a/src/lexer/tests.rs +++ b/src/lexer/tests.rs @@ -143,8 +143,6 @@ fn lex_all_keywords() { ("start", TokenKind::KwStart), ("transition", TokenKind::KwTransition), ("sprite", TokenKind::KwSprite), - ("background", TokenKind::KwBackground), - ("palette", TokenKind::KwPalette), ("draw", TokenKind::KwDraw), ("play", TokenKind::KwPlay), ("asm", TokenKind::KwAsm), diff --git a/src/lexer/token.rs b/src/lexer/token.rs index d76e82c..f232b62 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -66,16 +66,12 @@ pub enum TokenKind { KwStart, KwTransition, KwSprite, - KwBackground, - KwPalette, KwSfx, KwMusic, KwDraw, KwPlay, KwStopMusic, KwStartMusic, - KwLoadBackground, - KwSetPalette, KwScroll, KwAsm, KwRaw, @@ -175,16 +171,12 @@ impl std::fmt::Display for TokenKind { Self::KwStart => write!(f, "start"), Self::KwTransition => write!(f, "transition"), Self::KwSprite => write!(f, "sprite"), - Self::KwBackground => write!(f, "background"), - Self::KwPalette => write!(f, "palette"), Self::KwSfx => write!(f, "sfx"), Self::KwMusic => write!(f, "music"), Self::KwDraw => write!(f, "draw"), Self::KwPlay => write!(f, "play"), Self::KwStopMusic => write!(f, "stop_music"), Self::KwStartMusic => write!(f, "start_music"), - Self::KwLoadBackground => write!(f, "load_background"), - Self::KwSetPalette => write!(f, "set_palette"), Self::KwScroll => write!(f, "scroll"), Self::KwAsm => write!(f, "asm"), Self::KwRaw => write!(f, "raw"), diff --git a/src/lib.rs b/src/lib.rs index 1e0c8fd..4270261 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,6 @@ pub mod analyzer; pub mod asm; pub mod assets; pub mod codegen; -pub mod debug; pub mod errors; pub mod ir; pub mod lexer; diff --git a/src/optimizer/mod.rs b/src/optimizer/mod.rs index 7160265..f5bfc17 100644 --- a/src/optimizer/mod.rs +++ b/src/optimizer/mod.rs @@ -114,39 +114,89 @@ fn strength_reduce_block(block: &mut IrBasicBlock) { } } - // Now scan for Mul ops where one operand is a known constant + // Now scan for Mul / Div / Mod / ShiftVar ops where one operand + // is a known constant. Each reduces to a shift, AND, or Add chain. let mut replacements: Vec<(usize, Vec)> = Vec::new(); for (i, op) in block.ops.iter().enumerate() { - if let IrOp::Mul(dest, a, b) = op { - // Check if b is a known constant - if let Some(&val) = constants.get(b) { - if val.is_power_of_two() && val > 1 { - let shift = val.trailing_zeros() as u8; - replacements.push((i, vec![IrOp::ShiftLeft(*dest, *a, shift)])); - } else if val == 3 { - // Mul by 3: tmp = a + a; dest = tmp + a - // We reuse dest as tmp in first step, then compute final - // Actually need a temp. Use dest for the intermediate. - replacements.push(( - i, - vec![IrOp::Add(*dest, *a, *a), IrOp::Add(*dest, *dest, *a)], - )); + match op { + IrOp::Mul(dest, a, b) => { + // Check if b is a known constant + if let Some(&val) = constants.get(b) { + if val.is_power_of_two() && val > 1 { + let shift = val.trailing_zeros() as u8; + replacements.push((i, vec![IrOp::ShiftLeft(*dest, *a, shift)])); + } else if val == 3 { + // Mul by 3: tmp = a + a; dest = tmp + a + // We reuse dest as tmp in first step, then compute final + replacements.push(( + i, + vec![IrOp::Add(*dest, *a, *a), IrOp::Add(*dest, *dest, *a)], + )); + } + continue; } - continue; - } - // Check if a is a known constant (commutative) - if let Some(&val) = constants.get(a) { - if val.is_power_of_two() && val > 1 { - let shift = val.trailing_zeros() as u8; - replacements.push((i, vec![IrOp::ShiftLeft(*dest, *b, shift)])); - } else if val == 3 { - replacements.push(( - i, - vec![IrOp::Add(*dest, *b, *b), IrOp::Add(*dest, *dest, *b)], - )); + // Check if a is a known constant (commutative) + if let Some(&val) = constants.get(a) { + if val.is_power_of_two() && val > 1 { + let shift = val.trailing_zeros() as u8; + replacements.push((i, vec![IrOp::ShiftLeft(*dest, *b, shift)])); + } else if val == 3 { + replacements.push(( + i, + vec![IrOp::Add(*dest, *b, *b), IrOp::Add(*dest, *dest, *b)], + )); + } } } + // `x / 2ⁿ` → right shift; `x % 2ⁿ` → AND with `2ⁿ - 1`. + // Only the divisor side is interesting — `const / x` and + // `const % x` still need the runtime routine. + IrOp::Div(dest, a, b) => { + if let Some(&val) = constants.get(b) { + if val.is_power_of_two() && val > 1 { + let shift = val.trailing_zeros() as u8; + replacements.push((i, vec![IrOp::ShiftRight(*dest, *a, shift)])); + } else if val == 1 { + // `x / 1 == x` — copy via `a | 0`. Reusing the + // existing LoadImm at `b` saves us from + // having to synthesize a new temp. + replacements.push((i, vec![IrOp::Or(*dest, *a, *b)])); + } + } + } + IrOp::Mod(dest, a, b) => { + if let Some(&val) = constants.get(b) { + if val.is_power_of_two() && val > 1 { + // `x % 2ⁿ == x & (2ⁿ - 1)`. We need a LoadImm + // temp for the mask; reuse `b`'s slot via a + // fresh LoadImm so later ops that read `b` + // still see the original divisor. Emitting a + // second LoadImm into the same temp is + // tolerated because use counts treat it as a + // redefinition. + let mask = val - 1; + replacements + .push((i, vec![IrOp::LoadImm(*b, mask), IrOp::And(*dest, *a, *b)])); + } else if val == 1 { + // `x % 1 == 0`. + replacements.push((i, vec![IrOp::LoadImm(*dest, 0)])); + } + } + } + IrOp::ShiftLeftVar(dest, a, b) => { + if let Some(&val) = constants.get(b) { + let count = val.min(8); + replacements.push((i, vec![IrOp::ShiftLeft(*dest, *a, count)])); + } + } + IrOp::ShiftRightVar(dest, a, b) => { + if let Some(&val) = constants.get(b) { + let count = val.min(8); + replacements.push((i, vec![IrOp::ShiftRight(*dest, *a, count)])); + } + } + _ => {} } } @@ -260,6 +310,41 @@ fn const_fold_block(block: &mut IrBasicBlock) { constants.insert(dest, result); } } + IrOp::Mul(dest, a, b) => { + if let (Some(&va), Some(&vb)) = (constants.get(&a), constants.get(&b)) { + let result = va.wrapping_mul(vb); + *op = IrOp::LoadImm(dest, result); + constants.insert(dest, result); + } + } + IrOp::Div(dest, a, b) => { + if let (Some(&va), Some(&vb)) = (constants.get(&a), constants.get(&b)) { + let result = if vb == 0 { 0 } else { va / vb }; + *op = IrOp::LoadImm(dest, result); + constants.insert(dest, result); + } + } + IrOp::Mod(dest, a, b) => { + if let (Some(&va), Some(&vb)) = (constants.get(&a), constants.get(&b)) { + let result = if vb == 0 { 0 } else { va % vb }; + *op = IrOp::LoadImm(dest, result); + constants.insert(dest, result); + } + } + IrOp::ShiftLeft(dest, a, count) => { + if let Some(&va) = constants.get(&a) { + let result = va.wrapping_shl(u32::from(count)); + *op = IrOp::LoadImm(dest, result); + constants.insert(dest, result); + } + } + IrOp::ShiftRight(dest, a, count) => { + if let Some(&va) = constants.get(&a) { + let result = va.wrapping_shr(u32::from(count)); + *op = IrOp::LoadImm(dest, result); + constants.insert(dest, result); + } + } _ => {} } } @@ -342,9 +427,13 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet) { IrOp::Add(_, a, b) | IrOp::Sub(_, a, b) | IrOp::Mul(_, a, b) + | IrOp::Div(_, a, b) + | IrOp::Mod(_, a, b) | IrOp::And(_, a, b) | IrOp::Or(_, a, b) | IrOp::Xor(_, a, b) + | IrOp::ShiftLeftVar(_, a, b) + | IrOp::ShiftRightVar(_, a, b) | IrOp::CmpEq(_, a, b) | IrOp::CmpNe(_, a, b) | IrOp::CmpLt(_, a, b) @@ -479,11 +568,15 @@ fn op_dest(op: &IrOp) -> Option { | IrOp::Add(d, _, _) | IrOp::Sub(d, _, _) | IrOp::Mul(d, _, _) + | IrOp::Div(d, _, _) + | IrOp::Mod(d, _, _) | IrOp::And(d, _, _) | IrOp::Or(d, _, _) | IrOp::Xor(d, _, _) | IrOp::ShiftLeft(d, _, _) | IrOp::ShiftRight(d, _, _) + | IrOp::ShiftLeftVar(d, _, _) + | IrOp::ShiftRightVar(d, _, _) | IrOp::Negate(d, _) | IrOp::Complement(d, _) | IrOp::CmpEq(d, _, _) diff --git a/src/optimizer/tests.rs b/src/optimizer/tests.rs index 43b2c07..f868466 100644 --- a/src/optimizer/tests.rs +++ b/src/optimizer/tests.rs @@ -227,6 +227,95 @@ fn strength_reduce_mul_by_3() { assert!(!has_mul, "Mul should have been replaced"); } +#[test] +fn strength_reduce_div_by_power_of_two() { + // Div(t2, t0, t1) where t1 = 4 -> ShiftRight(t2, t0, 2) + let t0 = IrTemp(0); + let t1 = IrTemp(1); + let t2 = IrTemp(2); + + let ops = vec![ + IrOp::LoadImm(t0, 40), + IrOp::LoadImm(t1, 4), + IrOp::Div(t2, t0, t1), + ]; + let mut prog = make_program(ops, IrTerminator::Return(Some(t2))); + strength_reduce(&mut prog); + + let block = &prog.functions[0].blocks[0]; + assert!( + block + .ops + .iter() + .any(|op| matches!(op, IrOp::ShiftRight(d, s, 2) if *d == t2 && *s == t0)), + "expected ShiftRight(t2, t0, 2), got {:?}", + block.ops + ); + assert!( + !block.ops.iter().any(|op| matches!(op, IrOp::Div(..))), + "Div should have been replaced" + ); +} + +#[test] +fn strength_reduce_mod_by_power_of_two() { + // Mod(t2, t0, t1) where t1 = 8 -> And(t2, t0, mask=7) + let t0 = IrTemp(0); + let t1 = IrTemp(1); + let t2 = IrTemp(2); + + let ops = vec![ + IrOp::LoadImm(t0, 19), + IrOp::LoadImm(t1, 8), + IrOp::Mod(t2, t0, t1), + ]; + let mut prog = make_program(ops, IrTerminator::Return(Some(t2))); + strength_reduce(&mut prog); + + let block = &prog.functions[0].blocks[0]; + assert!( + block + .ops + .iter() + .any(|op| matches!(op, IrOp::And(d, a, _) if *d == t2 && *a == t0)), + "expected And(t2, t0, _), got {:?}", + block.ops + ); + assert!( + !block.ops.iter().any(|op| matches!(op, IrOp::Mod(..))), + "Mod should have been replaced" + ); +} + +#[test] +fn strength_reduce_shift_var_with_constant_amount() { + // ShiftLeftVar(t2, t0, t1) where t1 = 3 -> ShiftLeft(t2, t0, 3). + // Regression: before the fix, lowering silently emitted + // ShiftLeft(..., 1) for *every* shift, so `x << 3` quietly + // miscompiled to `x << 1`. + let t0 = IrTemp(0); + let t1 = IrTemp(1); + let t2 = IrTemp(2); + + let ops = vec![ + IrOp::LoadImm(t0, 2), + IrOp::LoadImm(t1, 3), + IrOp::ShiftLeftVar(t2, t0, t1), + ]; + let mut prog = make_program(ops, IrTerminator::Return(Some(t2))); + strength_reduce(&mut prog); + + let block = &prog.functions[0].blocks[0]; + assert!( + block + .ops + .iter() + .any(|op| matches!(op, IrOp::ShiftLeft(d, s, 3) if *d == t2 && *s == t0)), + "expected ShiftLeft(t2, t0, 3), got {:?}", + block.ops + ); +} + #[test] fn strength_reduce_leaves_non_power() { // Mul by 5 should NOT be replaced diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 5306450..f3a8ff4 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -10,8 +10,6 @@ pub struct Program { pub functions: Vec, pub states: Vec, pub sprites: Vec, - pub palettes: Vec, - pub backgrounds: Vec, pub sfx: Vec, pub music: Vec, pub banks: Vec, @@ -55,20 +53,6 @@ pub struct SpriteDecl { pub span: Span, } -#[derive(Debug, Clone)] -pub struct PaletteDecl { - pub name: String, - pub colors: Vec, - pub span: Span, -} - -#[derive(Debug, Clone)] -pub struct BackgroundDecl { - pub name: String, - pub chr_source: AssetSource, - pub span: Span, -} - /// `sfx Name { ... }` — a sound effect played on pulse 1. SFX are /// frame-accurate envelopes: `pitch[i]` and `volume[i]` describe the /// $4002/$4000 register state for frame `i`, advancing one entry per @@ -343,8 +327,6 @@ pub enum Statement { Transition(String, Span), WaitFrame(Span), Call(String, Vec, Span), - LoadBackground(String, Span), - SetPalette(String, Span), Scroll(Expr, Expr, Span), /// debug.log(expr, ...) — writes values to the emulator debug port. /// Stripped in release mode. @@ -357,14 +339,14 @@ pub enum Statement { /// skip variable substitution for completely unmanaged bytes. InlineAsm(String, Span), RawAsm(String, Span), - /// Audio: `play SfxName` — trigger a one-shot sound effect. - /// Currently a no-op at codegen time; no audio driver exists. + /// Audio: `play SfxName` — trigger a one-shot sound effect on pulse 1. + /// Compiles to a trigger sequence plus an envelope pointer store; + /// the runtime NMI tick walks the envelope at one byte per frame. Play(String, Span), - /// Audio: `start_music TrackName` — begin playing background music. - /// Currently a no-op at codegen time. + /// Audio: `start_music TrackName` — begin playing a looping music + /// track on pulse 2, driven by the same NMI tick. StartMusic(String, Span), - /// Audio: `stop_music` — stop any currently-playing music. - /// Currently a no-op at codegen time. + /// Audio: `stop_music` — silence pulse 2 and the music state machine. StopMusic(Span), } @@ -384,8 +366,6 @@ impl Statement { | Self::Transition(_, s) | Self::WaitFrame(s) | Self::Call(_, _, s) - | Self::LoadBackground(_, s) - | Self::SetPalette(_, s) | Self::Scroll(_, _, s) | Self::DebugLog(_, s) | Self::DebugAssert(_, s) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 130a658..b9f559f 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -127,8 +127,6 @@ impl Parser { let mut functions = Vec::new(); let mut states = Vec::new(); let mut sprites = Vec::new(); - let mut palettes = Vec::new(); - let mut backgrounds = Vec::new(); let mut sfx = Vec::new(); let mut music = Vec::new(); let mut banks = Vec::new(); @@ -165,12 +163,6 @@ impl Parser { TokenKind::KwSprite => { sprites.push(self.parse_sprite_decl()?); } - TokenKind::KwPalette => { - palettes.push(self.parse_palette_decl()?); - } - TokenKind::KwBackground => { - backgrounds.push(self.parse_background_decl()?); - } TokenKind::KwSfx => { sfx.push(self.parse_sfx_decl()?); } @@ -243,8 +235,6 @@ impl Parser { functions, states, sprites, - palettes, - backgrounds, sfx, music, banks, @@ -672,106 +662,6 @@ impl Parser { }) } - fn parse_palette_decl(&mut self) -> Result { - let start = self.current_span(); - self.expect(&TokenKind::KwPalette)?; - let (name, _) = self.expect_ident()?; - self.expect(&TokenKind::LBrace)?; - - let mut colors = None; - - while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { - let (key, _) = self.expect_ident()?; - self.expect(&TokenKind::Colon)?; - match key.as_str() { - "colors" => { - self.expect(&TokenKind::LBracket)?; - let mut vals = Vec::new(); - while *self.peek() != TokenKind::RBracket && *self.peek() != TokenKind::Eof { - if let TokenKind::IntLiteral(v) = self.peek().clone() { - self.advance(); - vals.push(v as u8); - } else { - return Err(Diagnostic::error( - ErrorCode::E0201, - format!("expected color index, found '{}'", self.peek()), - self.current_span(), - )); - } - if *self.peek() == TokenKind::Comma { - self.advance(); - } - } - self.expect(&TokenKind::RBracket)?; - colors = Some(vals); - } - _ => { - return Err(Diagnostic::error( - ErrorCode::E0201, - format!("unknown palette property '{key}'"), - self.current_span(), - )); - } - } - } - self.expect(&TokenKind::RBrace)?; - - let colors = colors.ok_or_else(|| { - Diagnostic::error( - ErrorCode::E0201, - "palette requires 'colors' property", - start, - ) - })?; - - Ok(PaletteDecl { - name, - colors, - span: Span::new(start.file_id, start.start, self.current_span().end), - }) - } - - fn parse_background_decl(&mut self) -> Result { - let start = self.current_span(); - self.expect(&TokenKind::KwBackground)?; - let (name, _) = self.expect_ident()?; - self.expect(&TokenKind::LBrace)?; - - let mut chr_source = None; - - while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { - let (key, _) = self.expect_ident()?; - self.expect(&TokenKind::Colon)?; - match key.as_str() { - "chr" => { - chr_source = Some(self.parse_asset_source()?); - } - _ => { - return Err(Diagnostic::error( - ErrorCode::E0201, - format!("unknown background property '{key}'"), - self.current_span(), - )); - } - } - } - self.expect(&TokenKind::RBrace)?; - - let chr_source = chr_source.ok_or_else(|| { - Diagnostic::error( - ErrorCode::E0201, - "background requires 'chr' property", - start, - ) - })?; - - Ok(BackgroundDecl { - name, - chr_source, - span: Span::new(start.file_id, start.start, self.current_span().end), - }) - } - // ── SFX / Music declarations ── /// `sfx Name { duty: N, pitch: [..], volume: [..] }`. Pitch and @@ -1170,18 +1060,6 @@ impl Parser { self.advance(); Ok(Statement::WaitFrame(span)) } - TokenKind::KwLoadBackground => { - let span = self.current_span(); - self.advance(); - let (name, _) = self.expect_ident()?; - Ok(Statement::LoadBackground(name, span)) - } - TokenKind::KwSetPalette => { - let span = self.current_span(); - self.advance(); - let (name, _) = self.expect_ident()?; - Ok(Statement::SetPalette(name, span)) - } TokenKind::KwScroll => { let span = self.current_span(); self.advance(); diff --git a/src/parser/tests.rs b/src/parser/tests.rs index c404018..54018ae 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -508,81 +508,6 @@ fn parse_sprite_decl() { } } -#[test] -fn parse_palette_decl() { - let src = r#" - game "Test" { mapper: NROM } - palette MainPal { - colors: [0x0F, 0x00, 0x10, 0x20] - } - on frame { wait_frame } - start Main - "#; - let prog = parse_ok(src); - assert_eq!(prog.palettes.len(), 1); - assert_eq!(prog.palettes[0].name, "MainPal"); - assert_eq!(prog.palettes[0].colors, vec![0x0F, 0x00, 0x10, 0x20]); -} - -#[test] -fn parse_background_decl() { - let src = r#" - game "Test" { mapper: NROM } - background TitleBg { - chr: @chr("title.png") - } - on frame { wait_frame } - start Main - "#; - let prog = parse_ok(src); - assert_eq!(prog.backgrounds.len(), 1); - assert_eq!(prog.backgrounds[0].name, "TitleBg"); - match &prog.backgrounds[0].chr_source { - AssetSource::Chr(path) => assert_eq!(path, "title.png"), - other => panic!("expected Chr, got {other:?}"), - } -} - -#[test] -fn parse_load_background_statement() { - let src = r#" - game "Test" { mapper: NROM } - state Title { - on frame { - load_background TitleBg - } - } - start Title - "#; - let prog = parse_ok(src); - let frame = prog.states[0].on_frame.as_ref().unwrap(); - assert_eq!(frame.statements.len(), 1); - match &frame.statements[0] { - Statement::LoadBackground(name, _) => assert_eq!(name, "TitleBg"), - other => panic!("expected LoadBackground, got {other:?}"), - } -} - -#[test] -fn parse_set_palette_statement() { - let src = r#" - game "Test" { mapper: NROM } - state Title { - on frame { - set_palette MainPal - } - } - start Title - "#; - let prog = parse_ok(src); - let frame = prog.states[0].on_frame.as_ref().unwrap(); - assert_eq!(frame.statements.len(), 1); - match &frame.statements[0] { - Statement::SetPalette(name, _) => assert_eq!(name, "MainPal"), - other => panic!("expected SetPalette, got {other:?}"), - } -} - // ── Audio subsystem: sfx / music declarations ── #[test] diff --git a/tests/integration_test.rs b/tests/integration_test.rs index dded0a0..f40626f 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -802,7 +802,7 @@ fn audio_pipeline_drops_period_table_cost_when_unused() { // ── M3 Tests ── #[test] -fn program_with_sprites_and_palette() { +fn program_with_inline_sprite_chr() { let source = r#" game "M3 Assets" { mapper: NROM } @@ -811,22 +811,10 @@ fn program_with_sprites_and_palette() { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] } - palette MainPal { - colors: [0x0F, 0x00, 0x10, 0x20] - } - - background TitleBg { - chr: @binary("title.bin") - } - var px: u8 = 128 var py: u8 = 120 state Title { - on enter { - load_background TitleBg - set_palette MainPal - } on frame { if button.right { px += 2 } if button.left { px -= 2 }