diff --git a/README.md b/README.md index f4fc752..12d7f14 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ start Main | [`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, scroll, type casting | | [`mmc1_banked.ne`](examples/mmc1_banked.ne) | MMC1 mapper, bank declarations, multiply | +| [`uxrom_user_banked.ne`](examples/uxrom_user_banked.ne) | UxROM mapper with a `bank Foo { fun ... }` block — first example to put real user code in a switchable bank, called via a generated cross-bank trampoline | | [`palette_and_background.ne`](examples/palette_and_background.ne) | Palette and background declarations, reset-time load, vblank-safe `set_palette` / `load_background` swaps | | [`friendly_assets.ne`](examples/friendly_assets.ne) | **Pleasant asset syntax** — named NES colours, grouped `bg0..sp3` palettes with `universal:`, ASCII pixel-art sprites, `legend { } + map:` tilemaps, `palette_map:` attribute grids, scalar sfx `pitch:`, note-name music with `tempo:` | | [`structs_enums_for.ne`](examples/structs_enums_for.ne) | Structs, enums, `for` loops, struct literals | diff --git a/examples/README.md b/examples/README.md index a7e8420..b54c938 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,6 +26,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `state_machine.ne` | on enter/exit, transitions | Multi-state flow with timers | | `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 | +| `uxrom_user_banked.ne` | UxROM, `bank Foo { fun ... }`, cross-bank trampoline | First example to put real user code inside a switchable bank. The animation step lives in `bank Extras` and is invoked from the fixed-bank state handler via a generated `__tramp_step_animation` stub that selects bank 0, JSRs the body, then restores the fixed bank before returning. | | `palette_and_background.ne` | palette, background, set_palette, load_background | Reset-time initial load plus vblank-safe runtime swaps | | `friendly_assets.ne` | named colours, grouped palette, pixel art, tilemap+legend, palette_map, scalar sfx pitch, note-name music | Exercises every "friendlier" asset syntax at once — the `palette` uses `bg0..sp3` + a shared `universal:`, the sprite is authored as ASCII pixel art, the background uses a `legend { ... } + map:` tilemap with a `palette_map:` for attributes, the sfx uses a scalar `pitch:` + `envelope:` alias, and the music uses note names (`C4, E4 40, rest 10`) with a `tempo:` default. | | `noise_triangle_sfx.ne` | `channel: noise`, `channel: triangle` on `sfx` blocks | Demonstrates the noise and triangle sfx channels. Declares one noise burst and one triangle bass note, plays each on a timer so the emulator harness captures both the pixel output and the APU state. | diff --git a/examples/uxrom_user_banked.ne b/examples/uxrom_user_banked.ne new file mode 100644 index 0000000..f57313a --- /dev/null +++ b/examples/uxrom_user_banked.ne @@ -0,0 +1,68 @@ +// UxROM User-Banked — first NEScript example to put real user code +// inside a switchable bank. The `bank Extras { fun ... }` block tells +// the linker that `step_animation` lives in the named PRG bank +// instead of the fixed bank; the IR codegen emits a +// `__tramp_step_animation` stub in the fixed bank, and every call +// from a state handler now goes through the trampoline (which +// selects bank 0 at $8000-$BFFF, JSRs the function, then restores +// the fixed bank before returning). +// +// UxROM is the friendliest mapper for this work because its +// `__bank_select` routine is a single bus-conflict-safe write to +// $FFF0; MMC1's serial shift register and MMC3's two-register dance +// would both work but add more moving parts to an already-large +// pipeline change. +// +// The example sweeps a smiley left-and-right entirely from inside +// the banked helper. The helper reads two globals (`px`, `dir`) and +// updates them in place — no parameters, no return value, so the +// fixed-bank call site is a single `JSR __tramp_step_animation`. +// The harness captures frame 180 somewhere along the sweep, so any +// regression in bank-switching, trampoline emission, or banked-stream +// assembly will flip the golden. +// +// Build: cargo run -- build examples/uxrom_user_banked.ne + +game "UxROM User Banked" { + mapper: UxROM + mirroring: horizontal +} + +// The banked helper drives `px` directly so the fixed-bank state +// handler is just `JSR __tramp_step_animation; draw Smiley`. `dir` +// is 0 while sweeping right and 1 while sweeping left. +var px: u8 = 110 +var dir: u8 = 0 + +bank Extras { + // Step the smiley one pixel each frame, bouncing off x = 110 + // and x = 150. The whole computation lives in the switchable + // bank: nothing here references any fixed-bank function, so the + // bank's instruction stream resolves cleanly during the linker's + // two-pass assembly without needing a second trampoline. + fun step_animation() { + if dir == 0 { + px = px + 1 + if px == 150 { + dir = 1 + } + } else { + px = px - 1 + if px == 110 { + dir = 0 + } + } + } +} + +on frame { + // Trampoline call: the codegen emits `JSR __tramp_step_animation` + // because the function is tagged `bank: Some("Extras")`. The + // trampoline lives in the fixed bank, switches to bank 0, JSRs + // `__ir_fn_step_animation` at its $8000-window address, then + // switches back before returning. + step_animation() + draw Smiley at: (px, 120) +} + +start Main diff --git a/examples/uxrom_user_banked.nes b/examples/uxrom_user_banked.nes new file mode 100644 index 0000000..c4cf2da Binary files /dev/null and b/examples/uxrom_user_banked.nes differ diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index eb189ff..4f3954c 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -78,8 +78,27 @@ pub fn analyze(program: &Program) -> AnalysisResult { // (`$11` flags + 2 × 3 pointer slots). Bump the user zero-page // start past that region so var allocation doesn't collide with // the runtime slots. + // + // Programs that nest user functions inside a `bank Foo { ... }` + // block additionally need to reserve `$10` for `ZP_BANK_CURRENT`, + // the slot that `__bank_select` writes the requested bank index + // into at every cross-bank call. Without this bump, the bank's + // first user variable lands on top of the same slot that + // `__bank_select` clobbers, producing nonsense at runtime. The + // bump only fires when the program actually has banked + // functions; programs that declare empty bank slots (the + // existing mmc1_banked / uxrom_banked / mmc3_per_state_split + // examples) keep the legacy layout because their fixed-bank + // user code never invokes `__bank_select`. let needs_ppu_update_slots = !program.palettes.is_empty() || !program.backgrounds.is_empty(); - let next_zp_addr = if needs_ppu_update_slots { 0x18 } else { 0x10 }; + let needs_bank_current_slot = program.functions.iter().any(|f| f.bank.is_some()); + let next_zp_addr = if needs_ppu_update_slots { + 0x18 + } else if needs_bank_current_slot { + 0x11 + } else { + 0x10 + }; let mut analyzer = Analyzer { symbols: HashMap::new(), var_allocations: Vec::new(), diff --git a/src/asm/mod.rs b/src/asm/mod.rs index 0b1d90a..66c66c2 100644 --- a/src/asm/mod.rs +++ b/src/asm/mod.rs @@ -14,6 +14,61 @@ pub fn assemble(instructions: &[Instruction], base_address: u16) -> AssembleResu assembler.assemble(instructions) } +/// Assemble like [`assemble`], but seed the assembler's label table +/// with externally-resolved labels first. Labels passed in are visible +/// to fixups in this assembly pass even though they're never defined +/// inside `instructions` — useful for cross-bank linking, where the +/// fixed bank's `JSR __ir_fn_foo` needs to resolve a label that lives +/// in a separately-assembled switchable bank. +/// +/// Definitions inside `instructions` shadow any seeded label of the +/// same name. The returned `labels` map contains both the seeded +/// entries and any labels defined in this pass — callers can use it +/// as the merged symbol table for symbol-file emission. +pub fn assemble_with_labels( + instructions: &[Instruction], + base_address: u16, + seed_labels: &HashMap, +) -> AssembleResult { + let mut assembler = Assembler::new(base_address); + for (name, addr) in seed_labels { + assembler.labels.insert(name.clone(), *addr); + } + assembler.assemble(instructions) +} + +/// Discovery-only assembly pass for cross-bank linking. Walks +/// `instructions` once, producing the label table (each label's +/// address inside the `base_address`-aligned window) and the raw +/// byte stream — but **skips fixup resolution entirely**. This lets +/// the linker discover what labels live inside a switchable bank +/// before the fixed bank has been assembled, without panicking on +/// fixups that reference still-unknown fixed-bank labels. +/// +/// Use [`assemble_with_labels`] for the final pass once the merged +/// label table is available; the discovery output is throwaway. +pub fn assemble_discover_labels( + instructions: &[Instruction], + base_address: u16, + seed_labels: &HashMap, +) -> AssembleResult { + let mut assembler = Assembler::new(base_address); + for (name, addr) in seed_labels { + assembler.labels.insert(name.clone(), *addr); + } + // First pass only — no fixup resolution. We still emit bytes so + // the per-instruction sizes are correct (label addresses are + // computed from the running output length), but unresolved + // label fixups stay as zero-byte placeholders. + for inst in instructions { + assembler.emit_instruction(inst); + } + AssembleResult { + bytes: assembler.output.clone(), + labels: assembler.labels.clone(), + } +} + /// Assemble a single instruction into bytes (no label resolution). pub fn assemble_instruction(opcode: Opcode, mode: &AddressingMode) -> Option> { let op = opcodes::encode(opcode, mode)?; diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index e9d631a..7b004cb 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -155,6 +155,26 @@ pub struct IrCodeGen<'a> { /// failing checks all land on the same debug marker. Skipped /// entirely in release builds. bounds_halt_used: bool, + /// Per-banked-function instruction streams, populated by + /// [`Self::generate`] when a program declares one or more + /// `bank Foo { fun ... }` blocks. The key is the bank name; the + /// value is the assembled IR codegen output for every function + /// assigned to that bank, ready to be handed to the linker as a + /// `PrgBank::with_instructions` payload. Empty for programs + /// without any banked user code, which keeps the codegen output + /// byte-for-byte identical to the pre-banked behaviour. + banked_streams: HashMap>, + /// Function name → declared bank name, populated from the IR + /// functions' `bank` field. Used to decide whether a `Call` op + /// should JSR the direct `__ir_fn_` label or the cross-bank + /// trampoline `__tramp_` label. + function_banks: HashMap, + /// While [`Self::generate`] is emitting code for a banked + /// function, this holds the bank name so cross-bank calls can be + /// disambiguated from in-bank calls. `None` means the codegen is + /// currently emitting fixed-bank code (handlers, runtime, the + /// dispatcher loop, top-level functions). + current_bank: Option, allocations: &'a [VarAllocation], } @@ -211,6 +231,18 @@ impl<'a> IrCodeGen<'a> { } } let function_names = ir.functions.iter().map(|f| f.name.clone()).collect(); + // Build the function-name → bank-name map from the IR. Most + // programs have an empty map (no `bank Foo { fun ... }` + // blocks); when populated, the codegen splits banked + // function bodies into per-bank instruction streams and + // rewrites `Call` ops to JSR a fixed-bank trampoline when + // they cross bank boundaries. + let mut function_banks: HashMap = HashMap::new(); + for func in &ir.functions { + if let Some(bank) = &func.bank { + function_banks.insert(func.name.clone(), bank.clone()); + } + } Self { instructions: Vec::new(), var_addrs, @@ -234,10 +266,25 @@ impl<'a> IrCodeGen<'a> { emit_source_locs: false, var_sizes, bounds_halt_used: false, + banked_streams: HashMap::new(), + function_banks, + current_bank: None, allocations, } } + /// Per-banked-function instruction streams produced by the most + /// recent [`Self::generate`] call. The map is keyed by bank name + /// (matching the program's `bank Foo { ... }` declarations) and + /// each value is ready to be handed to the linker as a + /// `PrgBank::with_instructions` payload. Empty for programs with + /// no banked code, in which case the linker treats every bank + /// as the legacy "reserved slot" mode. + #[must_use] + pub fn banked_streams(&self) -> &HashMap> { + &self.banked_streams + } + /// Enable source-location marker emission. When set, each /// [`IrOp::SourceLoc`] lowers to a uniquely-named /// label-definition pseudo-op and is recorded in @@ -600,8 +647,15 @@ impl<'a> IrCodeGen<'a> { } self.emit(JMP, AM::Label(main_loop)); - // 4. Emit each function body (state handlers + user functions) + // 4. Emit each fixed-bank function body (state handlers + + // top-level user functions). Functions tagged with `bank: + // Some(name)` belong to a switchable bank and are emitted + // separately into `self.banked_streams` after the fixed-bank + // pass finishes — see the loop further down. for func in &ir.functions { + if func.bank.is_some() { + continue; + } self.gen_function(func); } @@ -645,7 +699,40 @@ impl<'a> IrCodeGen<'a> { self.emit(JMP, AM::Label("__debug_halt".to_string())); } - std::mem::take(&mut self.instructions) + // Snapshot the fixed-bank instruction stream before we + // start emitting the banked function bodies into their own + // streams. Programs without any banked functions skip the + // banked-emission loop entirely so the codegen output is + // byte-for-byte identical to the pre-banked behaviour. + let fixed_instructions = std::mem::take(&mut self.instructions); + + // 6. For each function tagged with a bank, redirect emission + // into a fresh per-bank instruction stream and call + // `gen_function`. The streams are keyed by bank name and + // collected into `self.banked_streams` for the linker to + // pick up via [`Self::banked_streams`]. + for func in &ir.functions { + let Some(bank_name) = func.bank.clone() else { + continue; + }; + // Pull the existing stream for this bank (if any) out + // of the map so subsequent functions in the same bank + // accumulate into one contiguous stream. The first + // function in a bank starts with an empty Vec. + let prev = self.banked_streams.remove(&bank_name).unwrap_or_default(); + let prior_instrs = std::mem::replace(&mut self.instructions, prev); + self.current_bank = Some(bank_name.clone()); + self.gen_function(func); + self.current_bank = None; + // Move the per-bank stream back into the map and + // restore whatever instruction buffer was active when + // we entered this iteration (always empty in the + // current pipeline, but we restore it for symmetry). + let bank_stream = std::mem::replace(&mut self.instructions, prior_instrs); + self.banked_streams.insert(bank_name, bank_stream); + } + + fixed_instructions } fn gen_function(&mut self, func: &IrFunction) { @@ -883,7 +970,46 @@ impl<'a> IrCodeGen<'a> { self.load_temp(*arg); self.emit(STA, AM::ZeroPage(0x04 + i as u8)); } - self.emit(JSR, AM::Label(format!("__ir_fn_{name}"))); + // Pick the right JSR target. Three cases: + // 1. Callee is in the fixed bank (most common): + // JSR `__ir_fn_` — the original behaviour. + // 2. Callee is in a switchable bank and the caller + // is in the fixed bank: JSR `__tramp_`, + // the linker-emitted trampoline that switches + // banks, calls the body, then switches back. + // 3. Caller and callee live in the same switchable + // bank: direct JSR to `__ir_fn_` works + // because both labels exist in the bank's own + // assembler pass. + // + // Cross-bank calls between two different switchable + // banks aren't supported in the first pass — the + // codegen panics rather than silently miscompiling. + let callee_bank = self.function_banks.get(name).cloned(); + let label = match (&self.current_bank, &callee_bank) { + (None, None) => format!("__ir_fn_{name}"), + (None, Some(_)) => format!("__tramp_{name}"), + (Some(from_bank), Some(to_bank)) if from_bank == to_bank => { + format!("__ir_fn_{name}") + } + (Some(from_bank), Some(to_bank)) => { + panic!( + "cross-bank call from bank '{from_bank}' to '{to_bank}' \ + is not supported (function '{name}'); only fixed-bank \ + callers can invoke banked functions in the v1 \ + user-banked codegen" + ); + } + (Some(_), None) => { + // Banked function calls a fixed-bank function. + // The fixed bank is always mapped at $C000-$FFFF + // so a direct JSR works without a trampoline — + // no bank-switching needed because we're already + // calling into the always-mapped window. + format!("__ir_fn_{name}") + } + }; + self.emit(JSR, AM::Label(label)); if let Some(d) = dest { // Return value is in A self.store_temp(*d); diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 67ac4d0..f715170 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -315,6 +315,7 @@ impl LoweringContext { locals: std::mem::take(&mut self.current_locals), param_count: fun.params.len(), has_return: fun.return_type.is_some(), + bank: fun.bank.clone(), source_span: fun.span, }); } @@ -377,6 +378,11 @@ impl LoweringContext { locals: std::mem::take(&mut self.current_locals), param_count: 0, has_return: false, + // State handlers always live in the fixed bank — the + // analyzer rejects state-handler nesting inside `bank` + // blocks because the NMI dispatcher and reset path JSR + // into them directly without going through a trampoline. + bank: None, source_span: state.span, }); } diff --git a/src/ir/mod.rs b/src/ir/mod.rs index 1579918..84f7765 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -74,6 +74,16 @@ pub struct IrFunction { pub locals: Vec, pub param_count: usize, pub has_return: bool, + /// When `Some(bank_name)`, this function was declared inside a + /// `bank Foo { fun ... }` block in the source and its compiled + /// bytes belong in the named switchable PRG bank instead of the + /// fixed bank. The codegen separates the per-bank instruction + /// streams during [`IrCodeGen::generate`] and the linker assembles + /// each bank into its own 16 KB slot. State handlers and any + /// top-level functions leave this `None` and live in the fixed + /// bank alongside the runtime — the only mode prior to user-banked + /// codegen. + pub bank: Option, pub source_span: Span, } diff --git a/src/linker/mod.rs b/src/linker/mod.rs index a8157c7..dab692e 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -60,38 +60,68 @@ pub struct SpriteData { /// places switchable banks in declaration order, followed by the /// fixed bank at the end. /// -/// `entry_label` is the optional trampoline entry point inside this -/// bank — when set, the linker emits a `__tramp_` stub in the -/// fixed bank that selects this bank and JSRs into the label. -/// `data` is raw bytes to splice verbatim (the compiler currently -/// only uses empty data and lets the linker pad with $FF). +/// `instructions` is the assembly stream the IR codegen produced for +/// any user functions assigned to this bank — the linker assembles +/// it at base $8000, captures the resulting label addresses, and +/// merges them into the fixed bank's symbol table so cross-bank +/// trampolines can resolve their targets. An empty `instructions` +/// list is the legacy "reserve a slot" mode where the bank is just +/// padded with $FF. +/// +/// `trampolines` lists each `(target_function, target_label)` pair +/// that needs a trampoline emitted in the fixed bank: callers JSR +/// the trampoline, the trampoline switches banks and JSRs the entry +/// label, then switches back. The IR codegen populates this list +/// from any function declared inside a `bank Foo { fun ... }` block +/// that's actually called from outside its bank. #[derive(Debug, Clone)] pub struct PrgBank { pub name: String, - pub data: Vec, - pub entry_label: Option, + pub instructions: Vec, + pub trampolines: Vec, +} + +/// A single cross-bank trampoline request. The linker emits one +/// `__tramp_` stub in the fixed bank for every entry in +/// this list — the stub pushes a fixed bank-select call, JSRs the +/// real function in the switchable bank, then restores the fixed +/// bank before returning. +#[derive(Debug, Clone)] +pub struct BankTrampoline { + /// Label callers will `JSR` (e.g. `__tramp_big_helper`). + pub tramp_label: String, + /// Label inside the switchable bank holding the function body. + /// Conventionally `__ir_fn_`. + pub entry_label: String, } impl PrgBank { /// Create an empty named bank. Convenience for the compiler, - /// which currently emits all user code into the fixed bank and - /// just wants switchable slots reserved for future use. + /// which uses this when a `bank Foo: prg` declaration has no + /// nested function bodies and exists only to reserve a 16 KB + /// switchable slot the linker pads with $FF. #[must_use] pub fn empty(name: impl Into) -> Self { Self { name: name.into(), - data: Vec::new(), - entry_label: None, + instructions: Vec::new(), + trampolines: Vec::new(), } } - /// Create a bank with a raw byte payload and no trampoline entry. + /// Create a bank populated with the IR codegen's instruction + /// stream for any functions assigned to it, plus the trampoline + /// requests that should be emitted in the fixed bank. #[must_use] - pub fn with_data(name: impl Into, data: Vec) -> Self { + pub fn with_instructions( + name: impl Into, + instructions: Vec, + trampolines: Vec, + ) -> Self { Self { name: name.into(), - data, - entry_label: None, + instructions, + trampolines, } } } @@ -313,6 +343,46 @@ impl Linker { let total_banks = switchable_banks.len() + 1; let fixed_bank_index = total_banks - 1; + // Discovery pass: assemble each switchable bank that has + // its own instruction stream so we know what labels live + // inside it and at what $8000-window address. The bytes + // produced here are discarded — any JSRs from the banked + // code into fixed-bank labels (math runtime, audio tick, + // other state handlers) will fail label resolution because + // the fixed bank hasn't been assembled yet. We catch the + // panic via a separate fixup-tolerant assembly variant + // below; for the discovery pass we just need the label + // addresses, so we seed the assembler with a placeholder + // mapping (every label resolves to $C000) that's enough to + // pass the second pass without panic. + // + // Banks with no instructions (the legacy "reserved slot" + // mode used by every existing banked example) skip this + // entirely and just contribute an empty payload below — the + // code path is byte-for-byte equivalent to the pre-banked + // codegen behaviour for those programs. + let mut cross_bank_labels: HashMap = HashMap::new(); + for bank in switchable_banks { + if bank.instructions.is_empty() { + continue; + } + // Use placeholder seeding so unresolved references to + // fixed-bank labels don't panic during the discovery + // pass. The bytes are discarded; only the label table + // matters here. + let placeholder = HashMap::new(); + let discovery = asm::assemble_discover_labels(&bank.instructions, 0x8000, &placeholder); + for (label, addr) in &discovery.labels { + if cross_bank_labels.contains_key(label) { + panic!( + "duplicate label '{label}' across switchable banks; \ + cannot resolve cross-bank reference" + ); + } + cross_bank_labels.insert(label.clone(), *addr); + } + } + let mut all_instructions = Vec::new(); // RESET entry point @@ -369,23 +439,26 @@ impl Linker { // User code (var init + main loop) all_instructions.extend(user_code.iter().cloned()); - // Bank-select subroutine plus a trampoline per declared bank - // that has an entry label. Emitted only for banked mappers - // (NROM has no switchable banks by definition). The helpers - // live in the fixed bank so they're always reachable at - // $C000-$FFFF regardless of which switchable bank is - // currently mapped at $8000. + // Bank-select subroutine plus one trampoline per banked + // function that the IR codegen reported as cross-bank-called. + // Emitted only for banked mappers (NROM has no switchable + // banks by definition). The helpers live in the fixed bank + // so they're always reachable at $C000-$FFFF regardless of + // which switchable bank is currently mapped at $8000. if self.mapper != Mapper::NROM { all_instructions.extend(runtime::gen_bank_select(self.mapper)); #[allow(clippy::cast_possible_truncation)] let fixed_bank_num = fixed_bank_index as u8; for (i, bank) in switchable_banks.iter().enumerate() { - if let Some(entry) = &bank.entry_label { - #[allow(clippy::cast_possible_truncation)] - let bank_num = i as u8; + if bank.trampolines.is_empty() { + continue; + } + #[allow(clippy::cast_possible_truncation)] + let bank_num = i as u8; + for tramp in &bank.trampolines { all_instructions.extend(runtime::gen_bank_trampoline( - &bank.name, - entry, + &tramp.tramp_label, + &tramp.entry_label, bank_num, fixed_bank_num, )); @@ -499,9 +572,19 @@ impl Linker { all_instructions.push(Instruction::new(NOP, AM::Label("__irq".into()))); all_instructions.extend(runtime::gen_irq()); - // Assemble everything at $C000 + // Assemble everything at $C000. The label-seed map is empty + // for programs without any banked user code, which keeps the + // result byte-identical to the pre-banked-codegen output for + // every existing example. Programs with banked functions get + // the per-bank label tables merged in here so cross-bank + // trampolines can resolve their `__ir_fn_` targets in + // the second-pass fixup. let base_addr = 0xC000; - let result = asm::assemble(&all_instructions, base_addr); + let result = if cross_bank_labels.is_empty() { + asm::assemble(&all_instructions, base_addr) + } else { + asm::assemble_with_labels(&all_instructions, base_addr, &cross_bank_labels) + }; // Build PRG ROM with vector table let mut prg = result.bytes; @@ -538,22 +621,46 @@ impl Linker { } // Multi-bank layout: each switchable bank is an independent - // 16 KB slot whose contents the linker takes verbatim from - // the caller, followed by the fixed bank (just assembled). + // 16 KB slot whose contents are either the assembled + // banked-instruction stream or empty padding (for "reserved + // slot" mode), followed by the fixed bank (just assembled). // For NROM (no switchable banks) this collapses to the // legacy single-bank path. + // + // For banks that hold their own instruction streams we run + // a second assembly pass here, this time seeding the + // assembler with both the cross-bank labels (other banks' + // discovery results) and the fixed bank's labels. This is + // the pass that resolves any fixups from banked code into + // the fixed bank — math runtime, audio tick, other state + // handlers, etc. if switchable_banks.is_empty() { builder.set_prg(prg); } else { + // Build the merged label table the bank assembler + // needs. Includes both the cross-bank labels gathered + // during the discovery pass and every label the fixed + // bank assembler just produced. + let mut merged_labels = cross_bank_labels.clone(); + for (name, addr) in &result.labels { + merged_labels.insert(name.clone(), *addr); + } let mut banks: Vec> = Vec::with_capacity(total_banks); for bank in switchable_banks { + let payload = if bank.instructions.is_empty() { + Vec::new() + } else { + let final_pass = + asm::assemble_with_labels(&bank.instructions, 0x8000, &merged_labels); + final_pass.bytes + }; assert!( - bank.data.len() <= 16384, + payload.len() <= 16384, "switchable bank '{}' exceeds 16 KB ({} bytes)", bank.name, - bank.data.len() + payload.len() ); - banks.push(bank.data.clone()); + banks.push(payload); } banks.push(prg); builder.set_prg_banks(banks); diff --git a/src/linker/tests.rs b/src/linker/tests.rs index d3d4097..a4dc8f5 100644 --- a/src/linker/tests.rs +++ b/src/linker/tests.rs @@ -403,19 +403,30 @@ fn link_banked_switchable_banks_are_padded_with_ff() { } #[test] -fn link_banked_preserves_switchable_bank_payload_bytes() { - // When a caller provides raw bytes for a switchable bank, the - // linker must splice them in verbatim at the start of that - // bank's slot. This is the hook the compiler uses to ship data - // tables without touching the fixed bank. +fn link_banked_assembles_switchable_bank_instructions() { + // When a caller populates a switchable bank's instruction + // stream, the linker must assemble those instructions at the + // bank's $8000 base and splice the resulting bytes into the + // bank's slot. We use a label + a couple of NOPs so the byte + // pattern is unambiguous: NOP NOP NOP would be three $EA bytes + // at the very start of the bank. let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::UxROM); let user_code = vec![Instruction::implied(NOP)]; - let data = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42, 0x13]; - let banks = vec![PrgBank::with_data("DataBank", data.clone())]; + let bank_code = vec![ + Instruction::new(NOP, AM::Label("__bank_payload".into())), + Instruction::implied(NOP), + Instruction::implied(NOP), + Instruction::implied(NOP), + ]; + let banks = vec![PrgBank::with_instructions( + "DataBank", + bank_code, + Vec::new(), + )]; let rom = linker.link_banked(&user_code, &[], &[], &[], &banks); - // Bank 0 starts at offset 16. Verify payload lands at the very - // start. - assert_eq!(&rom[16..16 + data.len()], &data[..]); + // Bank 0 starts at offset 16. Verify the three NOP bytes land + // at the very start (the label pseudo-op emits zero bytes). + assert_eq!(&rom[16..19], &[0xEA, 0xEA, 0xEA]); } #[test] @@ -455,28 +466,30 @@ fn link_banked_fixed_bank_contains_bank_select_subroutine() { #[test] fn link_banked_fixed_bank_contains_trampolines_for_declared_banks() { - // When a bank declares an entry label, the linker must emit a - // matching `__tramp_` stub in the fixed bank. We check - // by constructing a bank with an entry label and verifying the - // assembled labels map (via the indirect check: the ROM builds - // without panicking on unresolved labels, which means the - // trampoline's target label — here spliced via a dummy NOP - // label in the fixed bank — resolved). + // When a bank requests a trampoline, the linker must emit a + // matching `__tramp_` stub in the fixed bank that JSRs + // the entry label inside the switchable bank. We check by + // constructing a bank with both an entry-label-defining + // instruction stream and a matching trampoline request, then + // verifying the linker doesn't panic on unresolved fixups (the + // banked-bank label seeding is what makes the JSR inside the + // trampoline resolve correctly). let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1); - // We splice a fake target label into the user code so the - // trampoline's internal JSR resolves. This simulates the path - // codegen will eventually take (emit the entry label alongside - // the banked user code; the linker resolves it via the banked - // assembler). - let user_code = vec![ - Instruction::new(NOP, AM::Label("__fake_bank_entry".into())), - Instruction::implied(NOP), + let user_code = vec![Instruction::implied(NOP)]; + // The switchable bank holds the entry label and a tiny RTS so + // there's something for the trampoline to JSR into. + let bank_code = vec![ + Instruction::new(NOP, AM::Label("__ir_fn_helper".into())), + Instruction::implied(RTS), ]; - let banks = vec![PrgBank { - name: "Level1".into(), - data: Vec::new(), - entry_label: Some("__fake_bank_entry".into()), - }]; + let banks = vec![PrgBank::with_instructions( + "Level1", + bank_code, + vec![BankTrampoline { + tramp_label: "__tramp_helper".into(), + entry_label: "__ir_fn_helper".into(), + }], + )]; // Should not panic — trampoline and entry label both present. let rom = linker.link_banked(&user_code, &[], &[], &[], &banks); let info = rom::validate_ines(&rom).unwrap(); diff --git a/src/main.rs b/src/main.rs index fc6a0c2..869d096 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use nescript::assets::{BackgroundData, PaletteData}; use nescript::codegen::IrCodeGen; use nescript::errors::render_diagnostics; use nescript::ir; -use nescript::linker::{render_mlb, render_source_map, LinkedRom, Linker, PrgBank}; +use nescript::linker::{render_mlb, render_source_map, BankTrampoline, LinkedRom, Linker, PrgBank}; use nescript::optimizer; use nescript::parser::ast::BankType; @@ -466,19 +466,60 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { // the program actually needed MMC1/MMC3 bank switching. // // For banked mappers (MMC1, UxROM, MMC3) we collect the - // declared `bank X: prg` entries and turn each into an empty - // 16 KB switchable slot. User code currently still lives in - // the fixed bank — the declared banks exist so programs that - // outgrow 16 KB have real ROM space to grow into and so - // mapper-specific fixtures (vectors, trampolines, bank-select - // helpers) land in the right place. + // declared `bank X: prg` entries and turn each into a 16 KB + // switchable slot. Programs that nest functions inside a + // `bank Foo { fun bar() { ... } }` block populate the matching + // slot with the IR codegen's per-bank instruction stream, plus + // a trampoline request for every nested function so the linker + // emits a `__tramp_` stub in the fixed bank for each + // cross-bank call site. Programs without nested functions still + // produce empty slots, which keeps existing banked ROMs + // (mmc1_banked, uxrom_banked, mmc3_per_state_split) byte-for-byte + // identical to the pre-banked-codegen output. let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper) .with_header(program.game.header); + // Run the peephole optimizer on each per-bank stream the same + // way we did for the fixed-bank stream. Mismatched optimization + // levels would only matter to programs with banked code (no + // existing example), but it's the right default. + let mut banked_streams: std::collections::HashMap> = + codegen.banked_streams().clone(); + for stream in banked_streams.values_mut() { + nescript::codegen::peephole::optimize(stream); + } + // For each declared bank, collect the trampoline requests from + // every banked function whose body lives in this bank. The + // codegen's `function_banks` map is the source of truth — but we + // don't expose it on the public API, so reconstruct the same + // mapping here by walking the IR. This keeps the linker + // independent of any codegen internal state beyond + // `banked_streams`. + let mut bank_trampolines: std::collections::HashMap> = + std::collections::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 switchable_banks: Vec = program .banks .iter() .filter(|b| b.bank_type == BankType::Prg) - .map(|b| PrgBank::empty(&b.name)) + .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, diff --git a/src/optimizer/tests.rs b/src/optimizer/tests.rs index 4492a68..6fe73e5 100644 --- a/src/optimizer/tests.rs +++ b/src/optimizer/tests.rs @@ -15,6 +15,7 @@ fn make_program(ops: Vec, terminator: IrTerminator) -> IrProgram { locals: vec![], param_count: 0, has_return: false, + bank: None, source_span: Span::new(0, 0, 0), }], globals: vec![], @@ -392,6 +393,7 @@ fn inline_removes_trivial() { locals: vec![], param_count: 0, has_return: false, + bank: None, source_span: Span::new(0, 0, 0), }; @@ -408,6 +410,7 @@ fn inline_removes_trivial() { locals: vec![], param_count: 0, has_return: true, + bank: None, source_span: Span::new(0, 0, 0), }; @@ -492,6 +495,7 @@ fn const_fold_preserves_loadimm_used_by_sibling_branch() { locals: vec![], param_count: 0, has_return: true, + bank: None, source_span: Span::new(0, 0, 0), }], globals: vec![], diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 4b02c90..096e1e5 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -259,6 +259,16 @@ pub struct FunDecl { pub return_type: Option, pub body: Block, pub is_inline: bool, + /// When `Some(bank_name)`, the function was declared inside a + /// `bank Foo { fun ... }` block and its compiled bytes live in + /// the named switchable PRG bank. Calls from the fixed bank to + /// this function go through a generated trampoline (see + /// `runtime::gen_bank_trampoline`); calls from inside the same + /// bank stay as direct JSRs. `None` means the function lives in + /// the fixed bank along with the runtime, NMI/IRQ handlers, and + /// every state handler — the only mode prior to the user-banked + /// codegen work. + pub bank: Option, pub span: Span, } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index a7b8478..4829f92 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -178,7 +178,15 @@ impl Parser { music.push(self.parse_music_decl()?); } TokenKind::KwBank => { - banks.push(self.parse_bank_decl()?); + let (bank, nested_funs) = self.parse_bank_decl()?; + banks.push(bank); + // Functions declared inside a `bank Foo { ... }` + // body land in the program's flat function list + // tagged with `bank: Some("Foo")`. The analyzer + // and the rest of the pipeline can then treat + // them uniformly while still knowing which bank + // each one belongs to. + functions.extend(nested_funs); } TokenKind::KwOn => { // Top-level `on frame` — implicit single state for M1 @@ -518,6 +526,11 @@ impl Parser { return_type, body, is_inline, + // Bank tagging happens after the function is parsed — + // top-level `fun` declarations leave it `None`, while + // nested `bank Foo { fun bar() ... }` bodies overwrite + // it to `Some("Foo")` on the way out of `parse_bank_decl`. + bank: None, span: Span::new(start.file_id, start.start, self.current_span().end), }) } @@ -610,29 +623,112 @@ impl Parser { } // ── Bank declaration ── + // + // Two forms are accepted: + // + // bank Foo: prg + // bank Foo: chr + // + // The "type-only" form. Reserves a 16 KB switchable PRG slot + // or claims a CHR bank. No body — used to declare named slots + // that the linker pads with $FF and that user code can grow + // into later. + // + // bank Foo { fun bar() { ... } fun baz() { ... } } + // + // The "nested-decls" form. The body holds zero or more + // function declarations whose code lands inside the named + // PRG bank instead of the fixed bank. Functions declared + // here are pushed onto `Program.functions` like any other + // function but tagged with `bank: Some("Foo")` so the + // codegen + linker know where to put them. + // + // The bank type is implicitly `prg` — there's no syntax for + // CHR-bank function nesting because CHR is data, not code. + // A bank declared this way can later be referenced from a + // fixed-bank function via a normal call expression; the + // codegen emits a trampoline in the fixed bank that switches + // banks and JSRs into the target. - fn parse_bank_decl(&mut self) -> Result { + /// Parse one bank declaration. Returns the [`BankDecl`] and a + /// vector of any function declarations nested inside the bank + /// body (empty for the type-only form). The caller appends the + /// nested functions to the program's flat function list, tagging + /// each one with `bank: Some(name)` so the rest of the pipeline + /// can treat them like any other function. + fn parse_bank_decl(&mut self) -> Result<(BankDecl, Vec), Diagnostic> { let start = self.current_span(); self.expect(&TokenKind::KwBank)?; let (name, _) = self.expect_ident()?; - self.expect(&TokenKind::Colon)?; - let (type_str, _) = self.expect_ident()?; - let bank_type = match type_str.as_str() { - "prg" => BankType::Prg, - "chr" => BankType::Chr, - _ => { - return Err(Diagnostic::error( - ErrorCode::E0201, - format!("expected 'prg' or 'chr', found '{type_str}'"), - self.current_span(), - )); + // Disambiguate between the two forms based on the next token. + // `:` introduces the bank type, `{` introduces a nested-decls + // body. Anything else is a parse error. + match self.peek() { + TokenKind::Colon => { + self.advance(); + let (type_str, _) = self.expect_ident()?; + let bank_type = match type_str.as_str() { + "prg" => BankType::Prg, + "chr" => BankType::Chr, + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("expected 'prg' or 'chr', found '{type_str}'"), + self.current_span(), + )); + } + }; + Ok(( + BankDecl { + name, + bank_type, + span: Span::new(start.file_id, start.start, self.current_span().end), + }, + Vec::new(), + )) } - }; - Ok(BankDecl { - name, - bank_type, - span: Span::new(start.file_id, start.start, self.current_span().end), - }) + TokenKind::LBrace => { + self.advance(); + let mut funs = Vec::new(); + while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { + match self.peek() { + TokenKind::KwFun | TokenKind::KwInline => { + let mut fun = self.parse_fun_decl()?; + fun.bank = Some(name.clone()); + funs.push(fun); + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "unexpected token '{}' inside bank body; only function \ + declarations are supported", + self.peek() + ), + self.current_span(), + )); + } + } + } + self.expect(&TokenKind::RBrace)?; + Ok(( + BankDecl { + name, + bank_type: BankType::Prg, + span: Span::new(start.file_id, start.start, self.current_span().end), + }, + funs, + )) + } + _ => Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "expected ':' or '{{' after bank name, found '{}'", + self.peek() + ), + self.current_span(), + )), + } } // ── Top-level on frame ── diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 825f85d..e753361 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -1480,34 +1480,36 @@ pub fn gen_bank_select(mapper: Mapper) -> Vec { /// Generate a cross-bank trampoline stub. Placed in the fixed bank /// and called by user code (also in the fixed bank) via -/// `JSR __tramp_`. Behavior: +/// `JSR `. Behavior: /// -/// 1. Save the caller's bank number (always the fixed bank's index). -/// 2. Load the target bank number into A, JSR `__bank_select`. -/// 3. JSR the entry label in the target bank. -/// 4. Load the fixed bank number, JSR `__bank_select` to restore. -/// 5. RTS. +/// 1. Load the target bank number into A, JSR `__bank_select`. +/// 2. JSR the user-supplied entry label inside the target bank. +/// 3. Load the fixed bank number, JSR `__bank_select` to restore. +/// 4. RTS. /// -/// `bank_name` is the user-declared bank name from the `.ne` source. -/// `entry_label` is the label inside that bank the trampoline -/// should call (conventionally `__bank__entry`). `bank_index` -/// is the physical bank number. `fixed_bank_index` is the physical -/// bank number of the fixed bank (always `total_banks - 1`). +/// `tramp_label` is the label that callers will JSR (the IR codegen +/// emits `JSR __tramp_` at every cross-bank call site). +/// `entry_label` is the label inside the target bank that holds the +/// callee's first instruction — conventionally `__ir_fn_`, +/// the same label IR codegen would have emitted for an in-bank call. +/// `bank_index` is the physical PRG bank number of the target bank. +/// `fixed_bank_index` is the physical bank number of the fixed bank +/// (always `total_banks - 1`). #[must_use] pub fn gen_bank_trampoline( - bank_name: &str, + tramp_label: &str, entry_label: &str, bank_index: u8, fixed_bank_index: u8, ) -> Vec { let mut out = Vec::new(); - let tramp_label = format!("__tramp_{bank_name}"); - out.push(Instruction::new(NOP, AM::Label(tramp_label))); + out.push(Instruction::new(NOP, AM::Label(tramp_label.to_string()))); // Switch to target bank. out.push(Instruction::new(LDA, AM::Immediate(bank_index))); out.push(Instruction::new(JSR, AM::Label("__bank_select".into()))); // Call the user's entry point in that bank. The label lives in - // the switchable bank and is resolved during banked assembly. + // the switchable bank and is resolved by the linker after the + // banked code is assembled. out.push(Instruction::new(JSR, AM::Label(entry_label.to_string()))); // Restore the fixed bank. out.push(Instruction::new(LDA, AM::Immediate(fixed_bank_index))); diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index 11916af..1ab227c 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -741,10 +741,12 @@ fn trampoline_switches_target_then_restores_fixed() { // A trampoline must JSR `__bank_select` twice: once with the // target bank's index, once with the fixed bank's index. The // two LDA immediates in the stub should match those two bank - // numbers in order. - let t = gen_bank_trampoline("Level1", "__bank_Level1_entry", 0, 3); + // numbers in order. The trampoline name is the label callers + // will JSR (one trampoline per banked function); the entry + // label is whatever lives in the switchable bank. + let t = gen_bank_trampoline("__tramp_helper", "__ir_fn_helper", 0, 3); // First instruction is the trampoline label. - assert!(matches!(&t[0].mode, AM::Label(n) if n == "__tramp_Level1")); + assert!(matches!(&t[0].mode, AM::Label(n) if n == "__tramp_helper")); // Extract the sequence of immediate loads. let imms: Vec = t .iter() @@ -772,7 +774,7 @@ fn trampoline_switches_target_then_restores_fixed() { .collect(); assert_eq!( jsrs, - vec!["__bank_select", "__bank_Level1_entry", "__bank_select"], + vec!["__bank_select", "__ir_fn_helper", "__bank_select"], "trampoline JSRs must dispatch in the correct order" ); // Final instruction returns to caller. @@ -780,11 +782,14 @@ fn trampoline_switches_target_then_restores_fixed() { } #[test] -fn trampoline_label_derives_from_bank_name() { - // Trampoline labels are consistently named `__tramp_` so - // codegen can reference them without knowing bank indices. - let t = gen_bank_trampoline("MusicData", "__music_entry", 1, 3); - assert!(matches!(&t[0].mode, AM::Label(n) if n == "__tramp_MusicData")); +fn trampoline_label_uses_caller_supplied_name() { + // The codegen picks the trampoline label (typically + // `__tramp_`) so it can JSR it from the call site + // without knowing bank indices. `gen_bank_trampoline` should + // emit that exact label as its leading pseudo-op so the + // assembler resolves the JSR. + let t = gen_bank_trampoline("__tramp_big_helper", "__ir_fn_big_helper", 1, 3); + assert!(matches!(&t[0].mode, AM::Label(n) if n == "__tramp_big_helper")); } #[test] diff --git a/tests/emulator/goldens/uxrom_user_banked.audio.hash b/tests/emulator/goldens/uxrom_user_banked.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/uxrom_user_banked.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/uxrom_user_banked.png b/tests/emulator/goldens/uxrom_user_banked.png new file mode 100644 index 0000000..a412dfa Binary files /dev/null and b/tests/emulator/goldens/uxrom_user_banked.png differ