mirror of
https://github.com/imjasonh/nescript
synced 2026-07-09 01:16:12 +00:00
codegen: user code in switchable banks via cross-bank trampolines
Adds a `bank Foo { fun bar() { ... } }` parser form so user functions
can opt into living in a switchable PRG bank instead of the fixed
bank, plus the IR codegen, runtime, and linker work to make calls
across the bank boundary actually run. Programs that don't use the
new syntax produce byte-identical ROMs to before — verified by
rebuilding every existing example and diffing.
Pipeline shape:
* Parser accepts both `bank Foo: prg` (legacy reserved slot) and
`bank Foo { fun ... }` (functions land in the named bank). Nested
functions get tagged `bank: Some("Foo")` on the FunDecl + IrFunction.
* Analyzer bumps the user zero-page start past `$10` whenever the
program declares any banked function, so `__bank_select`'s STA into
ZP_BANK_CURRENT can't clobber a user variable. Programs without
banked functions keep the legacy `$10` start.
* IrCodeGen emits each banked function into its own per-bank
instruction stream (`banked_streams: HashMap<String, Vec<Instruction>>`)
while the fixed-bank stream gets the dispatcher loop + state
handlers + top-level functions, exactly like before. Cross-bank
calls from the fixed bank rewrite `JSR __ir_fn_<name>` to
`JSR __tramp_<name>`; in-bank calls stay direct. Banked → fixed
calls are direct (the fixed bank is always mapped at $C000-$FFFF).
Banked → other-banked calls aren't supported in this pass and
panic loudly during codegen.
* Runtime's `gen_bank_trampoline` takes the trampoline label and
entry label as parameters now (one trampoline per banked function,
not one per bank) so the linker can request any number of stubs.
* Linker assembles banked banks twice: a discovery pass to learn
each bank's labels, then a final pass that seeds the merged label
table so banked code can JSR into the fixed bank's runtime helpers
(math, audio, etc.). The fixed-bank assembler is also seeded with
the cross-bank labels so the trampolines' `JSR __ir_fn_<name>`
resolves into the bank's $8000 window. New `asm::assemble_with_labels`
/ `asm::assemble_discover_labels` helpers wire this up.
* PrgBank carries `Vec<Instruction>` + a list of `BankTrampoline`
requests now, replacing the old `data: Vec<u8>` + single
`entry_label: Option<String>` shape. The compiler populates both
from the codegen output; the linker's two-pass assembly handles
the rest.
New example: `examples/uxrom_user_banked.ne` puts a sprite-stepping
helper inside `bank Extras { fun step_animation() { ... } }`. The
fixed-bank state handler calls it via the generated trampoline, and
the harness golden locks in pixel + audio output at frame 180.
UxROM is the only mapper exercised by the new example. MMC1 and
MMC3 also work through the same path (the linker emits the right
mapper-specific bank-select code), but no example uses them yet —
the existing `mmc1_banked.ne` / `mmc3_per_state_split.ne` keep
their fixed-bank-only layout.
Limitations carried forward:
* No banked → banked cross-bank calls (panics in codegen).
* No greedy size-packing; placement is explicit-only.
* MMC3 state handlers don't get banked (the per-state split path
is untouched).
This commit is contained in:
parent
201664ea04
commit
2fe943b056
19 changed files with 683 additions and 118 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<S: std::hash::BuildHasher>(
|
||||
instructions: &[Instruction],
|
||||
base_address: u16,
|
||||
seed_labels: &HashMap<String, u16, S>,
|
||||
) -> 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<S: std::hash::BuildHasher>(
|
||||
instructions: &[Instruction],
|
||||
base_address: u16,
|
||||
seed_labels: &HashMap<String, u16, S>,
|
||||
) -> 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<Vec<u8>> {
|
||||
let op = opcodes::encode(opcode, mode)?;
|
||||
|
|
|
|||
|
|
@ -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<String, Vec<Instruction>>,
|
||||
/// 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_<name>` label or the cross-bank
|
||||
/// trampoline `__tramp_<name>` label.
|
||||
function_banks: HashMap<String, String>,
|
||||
/// 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<String>,
|
||||
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<String, String> = 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<String, Vec<Instruction>> {
|
||||
&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_<name>` — the original behaviour.
|
||||
// 2. Callee is in a switchable bank and the caller
|
||||
// is in the fixed bank: JSR `__tramp_<name>`,
|
||||
// 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_<name>` 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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,16 @@ pub struct IrFunction {
|
|||
pub locals: Vec<IrLocal>,
|
||||
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<String>,
|
||||
pub source_span: Span,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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_<name>` 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<u8>,
|
||||
pub entry_label: Option<String>,
|
||||
pub instructions: Vec<Instruction>,
|
||||
pub trampolines: Vec<BankTrampoline>,
|
||||
}
|
||||
|
||||
/// A single cross-bank trampoline request. The linker emits one
|
||||
/// `__tramp_<fn_name>` 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_<fn_name>`.
|
||||
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<String>) -> 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<String>, data: Vec<u8>) -> Self {
|
||||
pub fn with_instructions(
|
||||
name: impl Into<String>,
|
||||
instructions: Vec<Instruction>,
|
||||
trampolines: Vec<BankTrampoline>,
|
||||
) -> 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<String, u16> = 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_<name>` 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<u8>> = 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);
|
||||
|
|
|
|||
|
|
@ -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_<name>` 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_<name>` 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();
|
||||
|
|
|
|||
57
src/main.rs
57
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<Vec<u8>, ()> {
|
|||
// 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_<name>` 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<String, Vec<nescript::asm::Instruction>> =
|
||||
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<String, Vec<BankTrampoline>> =
|
||||
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<PrgBank> = 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,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ fn make_program(ops: Vec<IrOp>, 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![],
|
||||
|
|
|
|||
|
|
@ -259,6 +259,16 @@ pub struct FunDecl {
|
|||
pub return_type: Option<NesType>,
|
||||
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<String>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<BankDecl, Diagnostic> {
|
||||
/// 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<FunDecl>), 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 ──
|
||||
|
|
|
|||
|
|
@ -1480,34 +1480,36 @@ pub fn gen_bank_select(mapper: Mapper) -> Vec<Instruction> {
|
|||
|
||||
/// Generate a cross-bank trampoline stub. Placed in the fixed bank
|
||||
/// and called by user code (also in the fixed bank) via
|
||||
/// `JSR __tramp_<bank_name>`. Behavior:
|
||||
/// `JSR <tramp_label>`. 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_<name>_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_<fn_name>` 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_<fn_name>`,
|
||||
/// 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<Instruction> {
|
||||
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)));
|
||||
|
|
|
|||
|
|
@ -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<u8> = 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_<bank>` 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_<fn_name>`) 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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue