mirror of
https://github.com/imjasonh/nescript
synced 2026-07-14 11:36:40 +00:00
Closes §H. 2×2 metatiles and a parallel collision map are now a
first-class construct. `metatileset Name { metatiles: [{ id, tiles,
collide }, ...] }` declares a library of 2×2 tile bundles. `room Name
{ metatileset: M, layout: [...] }` lays them out on a 16×15 grid. The
compiler expands each room at compile time into:
- a 960-byte nametable (`__room_tiles_<name>`)
- a 64-byte attribute table (`__room_attrs_<name>`)
- a 240-byte collision bitmap (`__room_col_<name>`)
`paint_room Name` reuses the vblank-safe `load_background` update
machinery for the nametable blit and installs the collision bitmap
pointer into `ZP_ROOM_COL_LO`/`ZP_ROOM_COL_HI` (ZP $18/$19).
`collides_at(x, y)` JSRs into a small runtime helper that reads
`(room_col),Y` with `Y = (y & 0xF0) | (x >> 4)` and returns 0/1.
The helper links in only when the `__collides_at_used` marker is
emitted, so programs that declare a room but never query it pay
zero bytes for the subroutine.
`parse_byte_array` grows a `[value; count]` shortcut — 240-entry
`layout` arrays are unwieldy to spell out a byte at a time.
See `examples/metatiles_demo.ne` for the end-to-end flow: a probe
sprite bounces off walls via `collides_at` and lands on the left
side of the playfield at frame 180 — direct evidence that the
collision query works.
Also defers the register-allocator work from §"Code quality /
tooling" and documents the audio-goldens constraint in future-work
so the next agent sees it.
274 lines
11 KiB
Rust
274 lines
11 KiB
Rust
//! End-to-end compile pipeline.
|
|
//!
|
|
//! One shared function ([`compile_source`]) drives the full
|
|
//! `preprocess → parse → analyze → IR lower → optimize →
|
|
//! codegen → peephole → link` sequence on an in-memory source
|
|
//! string. The CLI ([`crate::main`]), the compile benchmark
|
|
//! (`benches/compile.rs`), and the integration-test helper
|
|
//! (`tests/integration_test.rs::compile_with_debug_artifacts`)
|
|
//! all route through this one function, so any future change to
|
|
//! the pipeline is picked up everywhere without hand-maintained
|
|
//! parallel copies.
|
|
//!
|
|
//! The CI `cargo test --all-targets` job used to panic for a
|
|
//! release where the bench's hand-maintained copy diverged from
|
|
//! the CLI after the banked-codegen landing — that class of bug
|
|
//! can't recur now that the bench calls [`compile_source`]
|
|
//! directly.
|
|
//!
|
|
//! This module deliberately takes **already-preprocessed source
|
|
//! text** and an **explicit source directory** rather than a
|
|
//! filesystem path, so it stays friendly to future WASM hosting:
|
|
//! the caller is the only layer that needs to touch `std::fs`.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
|
|
use crate::analyzer::{self, AnalysisResult};
|
|
use crate::asm::Instruction;
|
|
use crate::assets::{self, BackgroundData, MusicData, PaletteData, SfxData};
|
|
use crate::codegen::{peephole, IrCodeGen};
|
|
use crate::errors::Diagnostic;
|
|
use crate::ir::{self, IrProgram};
|
|
use crate::lexer::Span;
|
|
use crate::linker::{BankTrampoline, LinkedRom, Linker, PrgBank, SpriteData};
|
|
use crate::optimizer;
|
|
use crate::parser;
|
|
use crate::parser::ast::BankType;
|
|
|
|
/// Knobs that mirror the CLI `build` flags. New knobs should
|
|
/// default to the "release build" value so that old callers pick
|
|
/// up sensible behaviour on upgrade.
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct CompileOptions {
|
|
/// Enable `--debug` mode: bounds checks, frame-overrun
|
|
/// counter, `debug.log` / `debug.assert` emission.
|
|
pub debug: bool,
|
|
/// Skip the IR optimizer. Matches `--no-opt`.
|
|
pub no_opt: bool,
|
|
/// Emit `__src_<N>` label pseudo-ops for every lowered IR
|
|
/// statement and record their spans on the codegen's
|
|
/// [`IrCodeGen::source_locs`] side table. The CLI turns this
|
|
/// on when `--source-map` is passed; the bench and release
|
|
/// builds leave it off because the labels become peephole
|
|
/// block boundaries and would shift ROM bytes.
|
|
pub emit_source_map: bool,
|
|
}
|
|
|
|
/// Everything the CLI, the bench, and the integration tests need
|
|
/// from a full compile run. Carries the raw ROM plus enough
|
|
/// metadata to render a memory map, emit a `.mlb` symbol file, or
|
|
/// emit a source map — whatever the caller wants to do with it.
|
|
pub struct CompileOutput {
|
|
/// Final assembled iNES ROM bytes (header + PRG + CHR).
|
|
pub rom: Vec<u8>,
|
|
/// Full linker result including the label table + fixed-bank
|
|
/// PRG file offset. Used for `.mlb` / source-map rendering.
|
|
pub link_result: LinkedRom,
|
|
/// Analyzer result, kept around for post-link reporters that
|
|
/// need the symbol table (`.mlb`) or the variable allocation
|
|
/// map (`--memory-map`).
|
|
pub analysis: AnalysisResult,
|
|
/// The IR program post-(optional) optimization, kept so
|
|
/// `--dump-ir` and the call-graph reporter have something to
|
|
/// print without re-running the lowering.
|
|
pub ir_program: IrProgram,
|
|
/// Resolved sprite data (CHR + tile indices).
|
|
pub sprites: Vec<SpriteData>,
|
|
/// Resolved sfx envelopes.
|
|
pub sfx: Vec<SfxData>,
|
|
/// Resolved music note streams.
|
|
pub music: Vec<MusicData>,
|
|
/// Resolved palette blobs.
|
|
pub palettes: Vec<PaletteData>,
|
|
/// Resolved background blobs.
|
|
pub backgrounds: Vec<BackgroundData>,
|
|
/// Final post-peephole fixed-bank instruction stream. Used by
|
|
/// `--asm-dump`.
|
|
pub instructions: Vec<Instruction>,
|
|
/// Source-location markers (`__src_<N>`, span) the codegen
|
|
/// emitted when [`CompileOptions::emit_source_map`] is set.
|
|
/// Empty when source maps are off.
|
|
pub source_locs: Vec<(String, Span)>,
|
|
}
|
|
|
|
/// Why the pipeline couldn't finish. The CLI translates each
|
|
/// variant into a human-readable error; tests and benches can
|
|
/// `unwrap()` with a sensible panic message.
|
|
#[derive(Debug)]
|
|
pub enum CompileError {
|
|
/// Parser produced one or more error-level diagnostics. The
|
|
/// caller gets the full diagnostic vector so it can render
|
|
/// whatever UI it wants.
|
|
Parse(Vec<Diagnostic>),
|
|
/// Parser returned `None` with no explicit errors (empty
|
|
/// input or similarly pathological).
|
|
ParseProducedNothing,
|
|
/// Analyzer produced one or more error-level diagnostics.
|
|
Analyze(Vec<Diagnostic>),
|
|
/// One of the asset resolvers (sprites, sfx, music, palette,
|
|
/// background) returned `Err`.
|
|
AssetResolution(String),
|
|
}
|
|
|
|
/// Run the full compile pipeline on an already-preprocessed
|
|
/// source string.
|
|
///
|
|
/// `source_dir` is used to resolve `@chr("…")` / `@palette("…")`
|
|
/// / `@nametable("…")` / `@binary("…")` paths that the parser
|
|
/// stored verbatim. Pass `Path::new(".")` when the program
|
|
/// doesn't reference any external assets.
|
|
///
|
|
/// Returns either a full [`CompileOutput`] or a [`CompileError`]
|
|
/// describing the first phase that refused to continue. The
|
|
/// caller is responsible for rendering diagnostics — this
|
|
/// function never prints to stdout or stderr.
|
|
pub fn compile_source(
|
|
source: &str,
|
|
source_dir: &Path,
|
|
opts: &CompileOptions,
|
|
) -> Result<CompileOutput, CompileError> {
|
|
// Parse.
|
|
let (program, parse_diags) = parser::parse(source);
|
|
if parse_diags.iter().any(Diagnostic::is_error) {
|
|
return Err(CompileError::Parse(parse_diags));
|
|
}
|
|
let program = program.ok_or(CompileError::ParseProducedNothing)?;
|
|
|
|
// Analyze.
|
|
let analysis = analyzer::analyze(&program);
|
|
if analysis.diagnostics.iter().any(Diagnostic::is_error) {
|
|
return Err(CompileError::Analyze(analysis.diagnostics));
|
|
}
|
|
|
|
// IR lowering plus (optionally) optimization.
|
|
let mut ir_program = ir::lower(&program, &analysis);
|
|
if !opts.no_opt {
|
|
optimizer::optimize(&mut ir_program);
|
|
}
|
|
|
|
// Asset resolution. Each asset category reads its paths
|
|
// relative to `source_dir`, so the caller picks which file
|
|
// system view is "current".
|
|
let sprites = assets::resolve_sprites(&program, source_dir)
|
|
.map_err(|e| CompileError::AssetResolution(format!("sprites: {e}")))?;
|
|
let sfx = assets::resolve_sfx(&program)
|
|
.map_err(|e| CompileError::AssetResolution(format!("sfx: {e}")))?;
|
|
let music = assets::resolve_music(&program)
|
|
.map_err(|e| CompileError::AssetResolution(format!("music: {e}")))?;
|
|
let palettes = assets::resolve_palettes(&program, source_dir)
|
|
.map_err(|e| CompileError::AssetResolution(format!("palettes: {e}")))?;
|
|
// Compute the first CHR tile index that backgrounds can claim.
|
|
// Sprite tile 0 is the runtime default smiley; the resolver
|
|
// packs user sprites in starting at tile 1, so the next free
|
|
// tile is whatever sits past the last sprite. We derive it
|
|
// from the resolved `SpriteData` rather than re-walking the
|
|
// AST to keep the two sides honest.
|
|
//
|
|
// Hard-error if the sprite range already fills the 256-tile
|
|
// pattern table. A silent cap would let a background tile
|
|
// overwrite the last sprite tile — the kind of latent
|
|
// miscompile we'd rather catch at link time than at runtime.
|
|
// The check is lifted out of `resolve_backgrounds` so the
|
|
// diagnostic mentions the sprite count, not just the
|
|
// background that happened to trip the limit.
|
|
let next_sprite_tile_u16 = sprites
|
|
.iter()
|
|
.map(|s| {
|
|
let count = s.chr_bytes.len().div_ceil(16) as u16;
|
|
u16::from(s.tile_index) + count
|
|
})
|
|
.max()
|
|
.unwrap_or(1u16);
|
|
let has_png_background = program.backgrounds.iter().any(|b| b.png_source.is_some());
|
|
if has_png_background && next_sprite_tile_u16 >= 256 {
|
|
return Err(CompileError::AssetResolution(format!(
|
|
"sprite tile range ends at index {next_sprite_tile_u16} which leaves no room for \
|
|
background tiles in the 256-tile pattern table; remove or shrink a sprite, or \
|
|
use an inline background body instead of `@nametable(...)`"
|
|
)));
|
|
}
|
|
#[allow(clippy::cast_possible_truncation)]
|
|
let next_sprite_tile: u8 = next_sprite_tile_u16.min(255) as u8;
|
|
let backgrounds = assets::resolve_backgrounds(&program, source_dir, next_sprite_tile)
|
|
.map_err(|e| CompileError::AssetResolution(format!("backgrounds: {e}")))?;
|
|
let rooms = assets::resolve_rooms(&program);
|
|
|
|
// IR → 6502 codegen. We hold on to the codegen after
|
|
// `generate()` because it carries the per-bank instruction
|
|
// streams and the source-location markers.
|
|
let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program)
|
|
.with_sprites(&sprites)
|
|
.with_audio(&sfx, &music)
|
|
.with_debug(opts.debug)
|
|
.with_source_map(opts.emit_source_map)
|
|
.with_debug_port(program.game.debug_port);
|
|
let mut instructions = codegen.generate(&ir_program);
|
|
peephole::optimize(&mut instructions);
|
|
|
|
// Pull the per-bank streams out, run peephole on each, and
|
|
// reconstruct the trampoline requests. Programs with no
|
|
// banked functions get empty maps here and the linker emits
|
|
// byte-identical output to the pre-banked-codegen baseline.
|
|
let mut banked_streams: HashMap<String, Vec<Instruction>> = codegen.banked_streams().clone();
|
|
for stream in banked_streams.values_mut() {
|
|
peephole::optimize(stream);
|
|
}
|
|
let mut bank_trampolines: HashMap<String, Vec<BankTrampoline>> = HashMap::new();
|
|
for func in &ir_program.functions {
|
|
if let Some(bank_name) = &func.bank {
|
|
bank_trampolines
|
|
.entry(bank_name.clone())
|
|
.or_default()
|
|
.push(BankTrampoline {
|
|
tramp_label: format!("__tramp_{}", func.name),
|
|
entry_label: format!("__ir_fn_{}", func.name),
|
|
});
|
|
}
|
|
}
|
|
|
|
let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper)
|
|
.with_header(program.game.header)
|
|
.with_battery(analysis.has_battery_saves)
|
|
.with_rooms(rooms);
|
|
let switchable_banks: Vec<PrgBank> = program
|
|
.banks
|
|
.iter()
|
|
.filter(|b| b.bank_type == BankType::Prg)
|
|
.map(|b| {
|
|
let stream = banked_streams.remove(&b.name).unwrap_or_default();
|
|
let tramps = bank_trampolines.remove(&b.name).unwrap_or_default();
|
|
if stream.is_empty() && tramps.is_empty() {
|
|
PrgBank::empty(&b.name)
|
|
} else {
|
|
PrgBank::with_instructions(&b.name, stream, tramps)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let link_result = linker.link_banked_with_ppu_detailed(
|
|
&instructions,
|
|
&sprites,
|
|
&sfx,
|
|
&music,
|
|
&palettes,
|
|
&backgrounds,
|
|
&switchable_banks,
|
|
);
|
|
|
|
let source_locs = codegen.source_locs().to_vec();
|
|
|
|
Ok(CompileOutput {
|
|
rom: link_result.rom.clone(),
|
|
link_result,
|
|
analysis,
|
|
ir_program,
|
|
sprites,
|
|
sfx,
|
|
music,
|
|
palettes,
|
|
backgrounds,
|
|
instructions,
|
|
source_locs,
|
|
})
|
|
}
|