1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 17:06:04 +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:
Claude 2026-04-14 11:41:20 +00:00
parent 201664ea04
commit 2fe943b056
No known key found for this signature in database
19 changed files with 683 additions and 118 deletions

View file

@ -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);