Another batch from the cc65/nesdoug catalogue. All gated on
parser-level opt-in or default-false attributes so existing
programs produce byte-identical ROMs (no committed .nes file
changed).
**§A — `i16` signed 16-bit type:**
- New `KwI16` lexer token, `NesType::I16` AST variant, parser
case in `parse_type`. Type-size and integer-type tables
treat `i16` like `u16` (2 bytes, integer).
- IR lowering accepts `i16` everywhere it accepts `u16` for
wide-load / wide-store / widen-narrow paths.
- New constant fold for `UnaryOp::Negate(IntLiteral(v))` that
emits the wide two's-complement form. Without it, `var vy:
i16 = -10` would zero-extend to `$00F6` (= 246) instead of
sign-extending to `$FFF6` (= -10). Negative literals now
store the right bytes.
- Comparisons reuse the existing unsigned 16-bit compare ops
(matching the existing `i8` behaviour). Documented in the
`NesType::I16` doc comment and in `future-work.md` §A.
- Example `examples/i16_demo.ne` with committed golden.
- Tests cover the literal-fold sign-extension and end-to-end
compile of the example.
**§S — SRAM / battery-backed saves:**
- New `save { var ... }` top-level block. Lexer + parser opt
into a dedicated `KwSave` token. Analyzer allocates save
vars from a separate `next_sram_addr` bump pointer starting
at `$6000`, capped at `$8000` (8 KB cartridge SRAM window).
- Linker reads `analysis.has_battery_saves` and flips iNES
byte-6 bit-1 via the new `RomBuilder::set_battery` /
`Linker::with_battery` chain.
- New `W0111` warning for save-var initializers — SRAM is
preserved across power cycles, so an init expression would
either silently never run or clobber persisted data on
every boot. The warning teaches the user about the
magic-byte sentinel pattern.
- Struct fields in save blocks are explicitly rejected for now
(the field-flattening path uses the main-RAM allocator).
- Example `examples/sram_demo.ne` with committed golden, plus
4 integration tests.
**§D (partial) — inline-asm `.label:` syntax:**
- Codegen-side mangler rewrites `.IDENT` → `__ilab_<N>_IDENT`
per inline-asm block, where `<N>` is the call site's
monotonic suffix. Two `asm { .loop: ... }` blocks in the
same function now coexist without colliding in the linker's
label table.
- Bounds checks on `.` placement: `$2002` and `name.field`
are unaffected; only `.IDENT` in label / branch context
triggers the rewrite. Two integration tests pin the
uniqueness and dollar-vs-dot disambiguation.
**§X follow-up — Mesen trace-log docs:**
- New "Debugger-assisted workflows" section in
`docs/nes-reference.md` walking through the Mesen / FCEUX
log workflows alongside the new `debug_port:` attribute.
**Misc:**
- `future-work.md` updated to mark the shipped items out of
the catalogue and reshuffle the priority ranking. Remaining
niche follow-ups (signedness on Cmp16, struct save fields,
inline-asm format specifiers) documented inline so future
passes know the design.
All 757 tests pass. Clippy clean. 46/46 emulator goldens match.
12 KiB
NES Hardware Quick Reference
A concise reference to the NES hardware for NEScript contributors. Understanding these constraints explains many of the compiler's design decisions.
CPU: Ricoh 2A03 (MOS 6502 variant)
- Clock speed: 1.79 MHz (NTSC), 1.66 MHz (PAL)
- Architecture: 8-bit data bus, 16-bit address bus
- Registers:
A(Accumulator) -- 8-bit, used for arithmetic and logicX(Index X) -- 8-bit, used for indexing and countingY(Index Y) -- 8-bit, used for indexing and countingSP(Stack Pointer) -- 8-bit, points into the $0100-$01FF rangeP(Status) -- 8-bit flags: N(egative), V(overflow), B(reak), D(ecimal), I(nterrupt), Z(ero), C(arry)PC(Program Counter) -- 16-bit
- No multiply, divide, or floating point instructions
- Instruction set: 56 official opcodes with multiple addressing modes (implied, immediate, zero page, zero page X/Y, absolute, absolute X/Y, indirect, indexed indirect, indirect indexed)
Why This Matters for NEScript
The 6502's 3-register architecture drives the compiler's register allocator. The lack of multiply/divide means the compiler emits software routines for *, /, and %. Zero-page addressing is 1 byte shorter and 1 cycle faster than absolute addressing, which is why fast variable placement matters.
Memory Map
| Address Range | Size | Description |
|---|---|---|
$0000-$00FF |
256 bytes | Zero page -- fast access |
$0100-$01FF |
256 bytes | Stack (grows downward) |
$0200-$02FF |
256 bytes | OAM shadow buffer (convention) |
$0300-$07FF |
1280 bytes | General purpose RAM |
$0800-$1FFF |
-- | Mirrors of $0000-$07FF |
$2000-$2007 |
8 bytes | PPU registers |
$2008-$3FFF |
-- | Mirrors of PPU registers |
$4000-$4017 |
24 bytes | APU and I/O registers |
$4018-$401F |
8 bytes | CPU test mode (normally disabled) |
$4020-$5FFF |
-- | Expansion ROM (mapper-dependent) |
$6000-$7FFF |
8 KB | SRAM / PRG RAM (if present) |
$8000-$BFFF |
16 KB | PRG ROM lower bank |
$C000-$FFFF |
16 KB | PRG ROM upper bank (or fixed bank) |
Interrupt Vectors
| Address | Vector | Description |
|---|---|---|
$FFFA |
NMI | Non-Maskable Interrupt (vertical blank) |
$FFFC |
RESET | Power-on / reset entry point |
$FFFE |
IRQ | Interrupt Request (mapper-dependent) |
NEScript Memory Usage
The compiler reserves the following:
$00-$0F: System use (frame counter, input, OAM cursor, SFX/music pointers, mul/div scratch)$10:ZP_BANK_CURRENT(current switchable PRG bank index, only in banked programs)$11-$17: PPU update slots (palette/nametable flags and pending pointers, only when the program declares palette or background blocks)$18-$7F: Available forfastvariables (user zero-page)$80-$FF: IR codegen temp slots (scratch for expression evaluation)$0200-$02FF: OAM shadow buffer (DMA'd to PPU each frame)$0300-$07EE: General variables, per-function RAM (parameter spill + locals), state-local storage$07EF:SPRITE_CYCLE_ADDR— rotating offset byte used bycycle_sprites(only when the program emits acycle_spritesstatement)$07F0-$07F7: Audio channel state (noise/triangle/sfx-pitch pointers; only when the program declares a matching sfx)$07FC:DEBUG_SPRITE_OVERFLOW_FLAG_ADDR— per-frame sticky bit (debug builds only)$07FD:DEBUG_SPRITE_OVERFLOW_COUNT_ADDR— cumulative PPU sprite overflow counter (debug builds only)$07FE:DEBUG_FRAME_OVERRUN_FLAG_ADDR— per-frame sticky bit (debug builds only)$07FF:DEBUG_FRAME_OVERRUN_ADDR— cumulative frame overrun counter (debug builds only)
Release-mode programs that don't opt into audio, banking, debug, or sprite cycling leave the corresponding slots untouched and the analyzer is free to allocate user globals over them.
PPU (Picture Processing Unit)
- Resolution: 256 x 240 pixels
- Refresh rate: 60 Hz (NTSC), 50 Hz (PAL)
- VRAM: 2 KB internal (2 nametables), expandable by mapper
- Pattern tables: 2 tables of 256 tiles each (one for backgrounds, one for sprites), stored in CHR ROM/RAM
- Tile size: 8x8 pixels (or 8x16 for tall sprites)
- Color depth: 2 bits per pixel (4 colors per tile, selected from a sub-palette)
Nametables
- 4 logical nametables, each 960 bytes of tile indices + 64 bytes of attribute data
- With 2 KB VRAM, 2 physical nametables exist; the other 2 are mirrors
- Mirroring arrangement (horizontal or vertical) is set by the cartridge/mapper
Sprites (OAM)
- 64 sprites total, each defined by 4 bytes:
- Byte 0: Y position
- Byte 1: Tile index
- Byte 2: Attributes (palette, priority, flip H/V)
- Byte 3: X position
- 8 sprites per scanline maximum (excess sprites are dropped)
- OAM is 256 bytes, typically shadow-buffered at CPU
$0200and DMA'd via$4014
Palettes
- Background: 4 sub-palettes, each with 4 colors (first color shared across all)
- Sprite: 4 sub-palettes, each with 4 colors (first color is transparent)
- Colors are indices into the NES master palette (64 entries,
$00-$3F) - Palette RAM is 32 bytes total at PPU
$3F00-$3F1F
PPU Registers
| Address | Name | Description |
|---|---|---|
$2000 |
PPUCTRL | NMI enable, sprite size, pattern table select, nametable select |
$2001 |
PPUMASK | Color emphasis, sprite/background enable, clipping |
$2002 |
PPUSTATUS | Vblank flag, sprite 0 hit, sprite overflow |
$2003 |
OAMADDR | OAM address for writes |
$2004 |
OAMDATA | OAM data read/write |
$2005 |
PPUSCROLL | Scroll position (write twice: X, Y) |
$2006 |
PPUADDR | VRAM address (write twice: high, low) |
$2007 |
PPUDATA | VRAM data read/write |
Rendering Timing
- Vblank starts at scanline 241 and lasts ~20 scanlines (~2,273 CPU cycles)
- PPU updates (palette, nametable, scroll) must happen during vblank
on framecode runs during the visible frame; the implicitwait_frame()yields until the next vblank- Approximately 29,780 CPU cycles per frame (NTSC)
APU (Audio Processing Unit)
| Channel | Type | Description |
|---|---|---|
| Pulse 1 | Square wave | Variable duty cycle (12.5/25/50/75%) |
| Pulse 2 | Square wave | Same as Pulse 1 |
| Triangle | Triangle | Fixed volume, good for bass |
| Noise | Noise | Pseudo-random, for percussion |
| DMC | Sample | 1-bit delta-modulated samples |
APU registers span $4000-$4017. The audio driver (included automatically when sfx or music declarations exist) runs during NMI.
iNES ROM Format
The standard ROM file format for NES emulators:
Offset Size Description
------ ------- -----------
0 4 bytes Magic number: "NES" followed by $1A
4 1 byte PRG ROM size in 16 KB units
5 1 byte CHR ROM size in 8 KB units
6 1 byte Flags 6: mapper (low nibble), mirroring, battery, trainer
7 1 byte Flags 7: mapper (high nibble), VS/Playchoice, NES 2.0
8-15 8 bytes Padding (zeros)
16+ varies PRG ROM data (N x 16384 bytes)
after varies CHR ROM data (N x 8192 bytes)
Common Mapper Numbers
| Number | Name | PRG ROM | CHR | Notes |
|---|---|---|---|---|
| 0 | NROM | 16/32 KB | 8 KB ROM | No bank switching |
| 1 | MMC1 | Up to 256 KB | Up to 128 KB | Switchable 16 KB PRG banks |
| 2 | UxROM | Up to 256 KB | 8 KB RAM | Switchable 16 KB PRG banks |
| 4 | MMC3 | Up to 512 KB | Up to 256 KB | Scanline counter, 8 KB banks |
Mirroring
- Horizontal: nametables A-A-B-B (vertical scrolling games)
- Vertical: nametables A-B-A-B (horizontal scrolling games)
- Set in byte 6, bit 0 of the iNES header (0 = horizontal, 1 = vertical)
Controller
Each controller has 8 buttons read as a serial shift register via $4016 (port 1) and $4017 (port 2).
Read sequence:
- Write
$01then$00to$4016to strobe (latch button states) - Read
$4016(or$4017) 8 times; bit 0 of each read is one button
Button order: A, B, Select, Start, Up, Down, Left, Right.
The NEScript runtime handles this automatically. The programmer reads button state via button.a, button.up, etc.
Key Constraints for NEScript
| Constraint | Limit | NEScript Response |
|---|---|---|
| Total RAM | 2 KB | Static allocation, no heap |
| Zero page | 256 bytes | fast/slow hints, compiler promotion |
| Stack | 256 bytes | Call depth limit, no recursion |
| Sprites per frame | 64 | Compiler manages OAM buffer |
| Sprites per scanline | 8 | Hardware limit, no workaround |
| Vblank time | ~2,273 cycles | PPU updates must be fast |
| Frame budget | ~29,780 cycles | Frame overrun detection in debug mode |
| No multiply/divide HW | -- | Software routines, power-of-2 optimization |
Debugger-assisted workflows
NEScript compiles three debugger-friendly sidecar files that Mesen,
Mesen2, and FCEUX can load alongside the .nes. All three are
off by default (release ROMs should be as small as possible) and
enabled per-run via CLI flags:
| Flag | Format | Consumers |
|---|---|---|
--symbols <path.mlb> |
Mesen labels (P: / R: lines) |
Mesen, Mesen2 |
--dbg <path.dbg> |
ca65 debug-info format | Mesen, Mesen2, FCEUX |
--fceux-labels <prefix> |
<prefix>.<bank>.nl + .ram.nl |
FCEUX |
Mesen trace-log
Mesen supports address-based execution tracing via its log /
save-log scripting APIs. Combined with NEScript's debug.log
builtin, the standard workflow is:
-
Build with
--debugand--symbols out.mlb.debug.log(value)writesvalueto the emulator debug port every time it executes. -
In the
game { }block, setdebug_port: mesenso writes land at$4018(Mesen's documented tracing port):game "MyGame" { mapper: NROM debug_port: mesen } -
In Mesen, enable the trace log (Debug → Trace Logger), load the
.mlb, and filter by memory operation on$4018. Eachdebug.log(x)call appears in the trace with the source function + line resolved from the label table.
For FCEUX on Linux, keep the default debug_port: fceux (writes
to $4800) and pass --fceux-labels out — FCEUX reads
out.<bank>.nl automatically when it opens the ROM from the same
directory. FCEUX's conditional-breakpoint syntax can match writes
to $4800 directly (A(4800)!=0) so you can break when any log
fires.