1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 09:18:01 +00:00

W0110 inline fallback warning + docs refresh

W0110: when a function marked `inline` has a body shape the IR
lowerer can't splice (conditional early return, loops, nested
control flow, empty void body), the analyzer now emits a
warning at the declaration site so the declined hint is
visible instead of silently falling back to a regular JSR.

Implementation:
  - New `W0110` error code in `src/errors/diagnostic.rs` (warning level).
  - New `pub fn can_inline_fun(return_type, body) -> bool` in
    `src/ir/lowering.rs`, extracted from the existing capture
    logic so the analyzer and the IR lowerer share the same
    eligibility rules and can never drift.
  - New `check_inline_declinability` analyzer pass called from
    the tail of `analyze_program`, mirroring the existing
    `check_sprite_scanline_budget` / `check_unreachable_states`
    passes. Emits W0110 with help + note text pointing at the
    two accepted body shapes.
  - `capture_inline_bodies` now defers to `can_inline_fun`
    instead of duplicating the match pattern, so the two sides
    stay in lockstep by construction.

Four regression tests in `src/analyzer/tests.rs` cover the
conditional-return and while-loop declines plus the two
accepted shapes (single-return expression, void sequence).

Example source cleanups: `wrap52` in `examples/war/deck.ne`
and `abs_diff` in both `examples/arrays_and_functions.ne` and
`examples/loop_break_continue.ne` drop the `inline` keyword.
All three were dead hints — the `inline` was being silently
declined before this change, so removing it is source-only;
the three ROMs are byte-identical, all 32 emulator goldens
still match.

Docs refresh
  - `docs/language-guide.md`: rewrote the Inline Functions section
    (real behaviour + W0110), added W0105/W0106/W0107/W0108/W0109/
    W0110 to the warnings table, added the `debug.sprite_overflow*`
    builtins + sprite-per-scanline mitigations section to the
    Debug Mode docs, added a `cycle_sprites` statement entry and
    cross-referenced it from `draw`.
  - `docs/nes-reference.md`: fleshed out the "NEScript Memory
    Usage" block with the full ZP + high-RAM layout, including
    the new `$07EF` / `$07FC` / `$07FD` slots for sprite cycling
    and the debug sprite-overflow telemetry.
  - `docs/future-work.md`: documented all four debug query
    builtins in the "What ships today" block; updated the open
    "OAM allocation strategy" question to reference the shipped
    `cycle_sprites` path and ask about an automatic-flicker
    game attribute as a follow-up.
  - `docs/architecture.md`: updated the `ir/` and `optimizer/`
    module summaries to describe real inline splicing (now
    in lowering, not the optimizer).
  - `README.md`: reframed the `inline` bullet from "hint" to
    "real splicing for single-return / void-body shapes";
    expanded the debug-support bullet to mention the four
    query builtins and their stripping in release builds; added
    a new bullet for the three-layer sprite-per-scanline
    mitigations; bumped the test count from 497 → 694; updated
    the war.ne entry to mention the seven compiler bugs are all
    fixed and point readers at `git log` (instead of the
    deleted COMPILER_BUGS.md).
  - `examples/README.md`: same `git log`-pointing rewrite for
    the war.ne entry.

Deletions
  - `examples/war/COMPILER_BUGS.md` is removed. All seven
    catalogued bugs are fixed; the file's historical value
    lives in `git log` now. Every source-code comment and doc
    reference to the file has been updated to either point at
    `git log` or just describe the bug in place.

Test count: 616 unit + 75 integration + 3 doctests = 694 total.
Clippy / fmt clean. 32/32 emulator goldens match.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
This commit is contained in:
Claude 2026-04-15 23:19:07 +00:00
parent 5e5bed39a5
commit 548787ac8a
No known key found for this signature in database
18 changed files with 487 additions and 858 deletions

View file

@ -41,10 +41,10 @@ Each module has a `mod.rs` (implementation) and a co-located `tests.rs` with uni
`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/`
`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.
`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`, resolves constant expressions early, and performs real `inline fun` splicing — functions marked `inline` whose bodies match one of the two splicable shapes (single `return <expr>` or void statement sequence) are captured before lowering begins and substituted at every call site. Bodies that don't match (conditional returns, loops, nested control flow) fall back to regular out-of-line calls and the analyzer emits `W0110` at the declaration.
### `optimizer/`
`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.
`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), constant folding (arithmetic + comparisons + shifts), copy propagation, and dead code elimination. The `inline fun` splicing happens one phase earlier in `ir/lowering.rs` so the optimizer sees already-inlined call sites.
### `codegen/`
`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).

View file

@ -151,11 +151,16 @@ op and writes a plain-text map of `<rom_offset> <file_id> <line> <col>`
entries for every lowered statement. Debug builds emit array bounds checks
(CMP against size, BCC past a `JMP __debug_halt` wedge) and bump an
overrun counter at `$07FF` in the NMI handler when the main loop didn't
reach `wait_frame` before the next vblank. **Plus** two new query
expressions: `debug.frame_overrun_count()` returns the cumulative counter
and `debug.frame_overran()` returns a per-frame sticky bit (cleared by
the next `wait_frame`) so user code can write
`debug.assert(not debug.frame_overran())` guards.
reach `wait_frame` before the next vblank.
**Plus four query expressions** that mirror the counter/sticky pattern:
`debug.frame_overrun_count()` / `debug.frame_overran()` return the cumulative
overrun counter and a per-frame sticky bit so user code can write
`debug.assert(not debug.frame_overran())` guards, and
`debug.sprite_overflow_count()` / `debug.sprite_overflow()` do the same for
the NES PPU's sprite-per-scanline flag (`$2002` bit 5), which the NMI
handler samples once per frame in debug mode. All four sticky bits clear
on the next `wait_frame`.
**Still TODO.**
- **`debug.overlay(x, y, text)`** — needs the text/HUD subsystem (see
@ -204,8 +209,13 @@ semantics come back, add the codes at that point.
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?
3. **OAM allocation strategy.** Sequential allocation remains the default;
the `cycle_sprites` opt-in keyword rotates the DMA offset each frame so
scenes past the 8-per-scanline budget flicker instead of dropping the
same sprite every frame. Open question: should automatic cycling become
a `game` attribute (`sprite_flicker: true`) that emits the increment
without requiring a per-frame call, and/or add a `draw ... priority:
pinned` modifier for HUD sprites that must stay at low OAM slots?
4. **Error recovery granularity.** How aggressively should the parser
recover? More recovery means more errors per compile but also risks
cascading false errors.

View file

@ -256,18 +256,42 @@ fun reset_score() {
### Inline Functions
The `inline` keyword hints the compiler to inline the function at call sites:
The `inline` keyword marks a function for inlining at call sites. The IR
lowering pass captures the body up front and substitutes it wherever the
function is called, skipping the normal `JSR` entirely. Two body shapes
are accepted:
**Single-return expression** — a function with a declared return type
whose body is exactly `{ return <expr> }`. The expression is re-lowered
in place of each call, with every parameter name substituted for the
caller's argument temps.
```
inline fun clamp(val: u8, max: u8) -> u8 {
if val > max {
return max
}
return val
inline fun card_rank(card: u8) -> u8 {
return card >> 4
}
```
`inline` is a hint -- the compiler may decline for large functions.
**Void multi-statement** — a function with no return type whose body is
a sequence of plain statements (assigns, calls, draws, scroll,
`set_palette`, `load_background`, `wait_frame`, `cycle_sprites`, inline
asm, or the `debug.*` builtins). Nested control flow, `return`,
`break`, `continue`, and `transition` are not allowed.
```
inline fun set_phase(p: u8) {
phase = p
phase_timer = 0
cursor_x = 0
}
```
Functions marked `inline` whose body doesn't match either shape (a
conditional early return, a `while` loop, nested `if`/`else`, etc.)
fall back to a regular out-of-line `JSR` call. The compiler emits a
`W0110` warning at the declaration site so the declined hint is
visible — rewrite the body to fit one of the two shapes, or drop the
`inline` keyword if the call overhead is acceptable.
### Calling Functions
@ -610,7 +634,11 @@ draw Player at: (player_x, player_y)
draw Coin at: (COIN_X, COIN_Y) frame: anim_frame
```
The `draw` statement writes to the OAM shadow buffer. The NES supports up to 64 sprites per frame.
The `draw` statement writes to the OAM shadow buffer. The NES supports
up to 64 sprites per frame, and the PPU can only render 8 sprites per
scanline — see the `cycle_sprites` statement below and the
[sprite-per-scanline mitigations](#sprite-per-scanline-mitigations)
section for how to handle scenes that exceed the 8-per-scanline budget.
Syntax: `draw SpriteName at: (x_expr, y_expr) [frame: expr]`
@ -634,6 +662,38 @@ wait_frame()
This triggers OAM DMA transfer and PPU updates before yielding. Inside `on frame`, a `wait_frame()` is implicit at the end of each frame.
### Cycle Sprites
Rotate the runtime's sprite-cycling offset by one OAM slot (4 bytes),
naturally wrapping at 256 back to 0. When any statement in a program
emits `cycle_sprites`, the linker switches the NMI handler over to a
variant that writes the current offset byte (at `$07EF`) to `$2003`
before triggering the OAM DMA — so each frame's DMA lands in a
different slot of the PPU's OAM buffer.
```
on frame {
draw Enemy0 at: (e0x, e0y)
draw Enemy1 at: (e1x, e1y)
// ...lots of enemies...
cycle_sprites
wait_frame
}
```
The practical effect is the classic NES flicker: scenes with more than
8 sprites on a single scanline drop a *different* sprite on each
frame, and the eye reconstructs the missing pixels from frame
persistence. Permanent dropout becomes visible flicker, which reads as
a hardware limit rather than a game bug.
`cycle_sprites` is opt-in by design. Programs that never call it emit
the original fixed-offset NMI path (byte-identical to every
pre-cycling ROM). See
[sprite-per-scanline mitigations](#sprite-per-scanline-mitigations)
for when to use it together with the compile-time `W0109` warning and
the debug-mode `debug.sprite_overflow*()` telemetry.
### Scroll
Set the PPU scroll position:
@ -1107,7 +1167,67 @@ In debug mode, the compiler inserts:
- Array bounds checking on indexed access
- Arithmetic overflow warnings
- Stack depth monitoring at function entry
- Frame overrun detection (warns if frame handler exceeds vblank period)
- Frame overrun detection (bumps a counter at `$07FF` whenever the
frame handler runs past vblank)
- Sprite-per-scanline overflow detection (bumps a counter at `$07FD`
whenever the PPU's sprite overflow flag at `$2002` bit 5 was set
for the just-finished frame)
### Debug Queries
Four builtin expressions let user code inspect the debug counters and
sticky bits. All four return a `u8`, peek a fixed runtime address in
debug builds, and compile to a constant zero in release builds (so
`debug.assert(not debug.frame_overran())` guards disappear entirely
when you ship).
```
var n: u8 = debug.frame_overrun_count() // cumulative overruns since reset
debug.assert(not debug.frame_overran()) // sticky bit, cleared on next wait_frame
var s: u8 = debug.sprite_overflow_count() // cumulative PPU sprite overflows
debug.assert(not debug.sprite_overflow()) // sticky bit, cleared on next wait_frame
```
The sprite overflow pair reads the NES hardware flag (`$2002` bit 5),
which has a few well-known quirks but is correct for the overwhelming
majority of cases. Use it together with the compile-time `W0109` static
check and the runtime `cycle_sprites` flicker mitigation — see the
sprite-per-scanline section below.
### Sprite-per-scanline mitigations
The NES PPU can only render 8 sprites per scanline. Anything past the
budget is silently dropped, and because sprites land in the shadow OAM
in draw order, the same sprite gets dropped every frame — a permanent
dropout that reads as a bug rather than a hardware limit. NEScript
ships three layers of mitigation:
1. **Compile time** — the `W0109` warning fires on layouts with more
than 8 literal-coordinate sprites overlapping any scanline. Catches
static HUDs, text labels, and title screens.
2. **Runtime** — the `cycle_sprites` keyword statement bumps a
rotating offset byte at `$07EF`. A cycling variant of the NMI
handler writes that byte to `$2003` before the OAM DMA, so each
frame's DMA lands in a different slot of the PPU's OAM buffer.
Over N frames each of the N overlapping sprites gets dropped
approximately once, producing visible flicker the eye
reconstructs from frame persistence — the classic NES idiom
used by Gradius, Battletoads, and every shmup.
3. **Playtesting**`debug.sprite_overflow()` /
`debug.sprite_overflow_count()` expose the PPU hardware flag as
debug queries so user code can assert the budget holds, or a
debug overlay can display the running count.
```
on frame {
// ... draw all your sprites ...
cycle_sprites // rotate one slot per frame
wait_frame
}
```
See `examples/sprite_flicker_demo.ne` for the end-to-end flow.
---
@ -1214,6 +1334,17 @@ reference NEScript variables.
| W0102 | Loop without break or wait_frame |
| W0103 | Unused variable |
| W0104 | Unreachable code (after return/break/transition, or state unreachable from start) |
| W0105 | Palette sub-palette universal mismatch (mirror collision) |
| W0106 | Implicit drop of non-void function return value |
| W0107 | `fast` variable rarely accessed (wastes a zero-page slot) |
| W0108 | Array elements past byte 255 unreachable via 8-bit X index |
| W0109 | More than 8 literal-coordinate sprites overlap one scanline (NES hardware limit — see `cycle_sprites` and `debug.sprite_overflow()` for runtime mitigations) |
| W0110 | `inline fun` body shape cannot be inlined; falling back to a regular `JSR` call (rewrite as a single-return expression or a void statement sequence, or drop the `inline` keyword) |
`nescript build` prints warnings in addition to errors on a successful
compile, so code-quality hints surface during normal development without
needing a separate `nescript check` pass. Errors still halt the build;
warnings never do.
### Example Error Output

View file

@ -53,10 +53,23 @@ The 6502's 3-register architecture drives the compiler's register allocator. The
### NEScript Memory Usage
The compiler reserves the following:
- `$00`-`$0F`: System use (frame counter, temp registers, NMI flags)
- `$10`-`$FF`: Available for `fast` variables and compiler-promoted variables
- `$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 for `fast` variables (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`-`$07FF`: General variables, state-local storage
- `$0300`-`$07EE`: General variables, per-function RAM (parameter spill + locals), state-local storage
- `$07EF`: `SPRITE_CYCLE_ADDR` — rotating offset byte used by `cycle_sprites` (only when the program emits a `cycle_sprites` statement)
- `$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.
---