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

codegen: support banked → banked cross-bank function calls

Programs that put functions in switchable banks can now call across
bank boundaries — `bank A { fun step() { helper() } }` where
`helper` lives in `bank B` used to panic in the IR codegen. Three
small pieces unblock it:

1. **Generic trampoline.** `runtime/gen_bank_trampoline` no longer
   takes a `fixed_bank_index` argument. Instead it reads the
   caller's current bank from `ZP_BANK_CURRENT`, pushes it on the
   hardware stack, switches to the target, JSRs the entry, then
   pulls and restores the saved bank. The same per-callee stub
   works for fixed→banked and banked→banked direction; nested
   trampolines compose because each PHA/PLA pair sits inside its
   own JSR/RTS frame. `gen_mapper_init` seeds `ZP_BANK_CURRENT`
   with the fixed bank index for any banked mapper so the very
   first cross-bank call from the fixed bank still restores to
   the fixed bank (matching pre-banked-banked semantics).

2. **Codegen drops the panic.** The `Some(from), Some(to)` arm in
   the call-resolution switch now emits `JSR __tramp_<name>` like
   the fixed→banked case instead of panicking. Banked→fixed calls
   still go direct (the fixed bank is always mapped at $C000).

3. **Bank-namespaced local labels.** Two banks emitting the same
   `__ir_cmp_e_8` would trip the linker's discovery-pass duplicate-
   label check the moment any banked code generated a comparison.
   The new `local_label_suffix` helper prefixes the suffix with the
   current bank name when banked code is being emitted, leaving
   fixed-bank label generation untouched (so existing examples are
   byte-identical apart from the trampoline / init bytes
   themselves).

The new `examples/uxrom_banked_to_banked.ne` demonstrates the path
end-to-end: `bank Logic { fun step() { ... clamp() } }` calls
`bank Helpers { fun clamp() { ... } }` once per frame. The harness
golden is committed alongside it. The five existing banked example
ROMs change byte-for-byte because of the new trampoline shape and
the seed-ZP_BANK_CURRENT init, but their emulator goldens still
match exactly — observable behaviour is unchanged.

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
This commit is contained in:
Claude 2026-04-15 02:37:19 +00:00
parent 7294ae3efa
commit db3a4adc57
No known key found for this signature in database
15 changed files with 403 additions and 66 deletions

View file

@ -1332,12 +1332,28 @@ pub fn gen_mapper_init(
mirroring: Mirroring,
total_prg_banks: usize,
) -> Vec<Instruction> {
match mapper {
let mut out = match mapper {
Mapper::NROM => Vec::new(),
Mapper::MMC1 => gen_mmc1_init(mirroring),
Mapper::UxROM => gen_uxrom_init(total_prg_banks),
Mapper::MMC3 => gen_mmc3_init(mirroring),
};
// Initialize ZP_BANK_CURRENT to the fixed bank index for any
// banked mapper. The trampoline emitted by
// `gen_bank_trampoline` reads this slot to decide which bank
// to restore after a cross-bank call, so it has to be a
// sensible value from the very first call. Without this the
// RAM-clear leaves it at $00, which would put bank 0 at
// $8000 instead of the fixed bank after a fixed-bank caller's
// first cross-bank call — a behavior change vs. the pre-
// banked-banked codegen that some examples rely on.
if mapper != Mapper::NROM && total_prg_banks > 0 {
#[allow(clippy::cast_possible_truncation)]
let fixed_bank_index = (total_prg_banks - 1) as u8;
out.push(Instruction::new(LDA, AM::Immediate(fixed_bank_index)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_BANK_CURRENT)));
}
out
}
/// MMC1 reset: pulse the reset bit, then write the control register.
@ -1517,13 +1533,28 @@ 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_label>`. Behavior:
/// and called by *any* user code via `JSR <tramp_label>` regardless
/// of which bank the caller currently lives in. Behavior:
///
/// 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.
/// 1. Read [`ZP_BANK_CURRENT`] into A, push it on the hardware
/// stack — that's the bank we'll need to switch back to.
/// 2. Load the target bank number into A, JSR `__bank_select`.
/// 3. JSR the user-supplied entry label inside the target bank.
/// 4. Pull the saved bank back into A and JSR `__bank_select` to
/// restore the caller's view of $8000-$BFFF.
/// 5. RTS.
///
/// The save/restore via `ZP_BANK_CURRENT + PHA/PLA` makes the same
/// trampoline work for **fixed-bank → switchable-bank** *and*
/// **switchable-bank → switchable-bank** call directions: the
/// caller's bank ends up restored regardless of where the call
/// originated. Nested cross-bank calls compose because each
/// trampoline's PHA/PLA pair is balanced against its own JSR/RTS,
/// so the saved bank values stack like any other 6502 frame.
///
/// The trampoline body itself lives in the fixed bank, which is
/// always mapped at `$C000-$FFFF`, so it's reachable from every
/// switchable bank without further mapper trickery.
///
/// `tramp_label` is the label that callers will JSR (the IR codegen
/// emits `JSR __tramp_<fn_name>` at every cross-bank call site).
@ -1531,17 +1562,21 @@ pub fn gen_bank_select(mapper: Mapper) -> Vec<Instruction> {
/// 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(
tramp_label: &str,
entry_label: &str,
bank_index: u8,
fixed_bank_index: u8,
) -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(NOP, AM::Label(tramp_label.to_string())));
// Save the caller's current bank. `__bank_select` writes its
// input into ZP_BANK_CURRENT, so this slot already mirrors the
// last-selected bank (initialized to the fixed bank index by
// `gen_mapper_init` so even fixed-bank callers see a sane
// value the first time around).
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_BANK_CURRENT)));
out.push(Instruction::implied(PHA));
// Switch to target bank.
out.push(Instruction::new(LDA, AM::Immediate(bank_index)));
out.push(Instruction::new(JSR, AM::Label("__bank_select".into())));
@ -1549,8 +1584,10 @@ pub fn gen_bank_trampoline(
// 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)));
// Restore the caller's bank (pulled from the stack) so control
// returns with $8000-$BFFF showing whatever the caller had
// mapped before the trampoline ran.
out.push(Instruction::implied(PLA));
out.push(Instruction::new(JSR, AM::Label("__bank_select".into())));
out.push(Instruction::implied(RTS));
out

View file

@ -574,17 +574,31 @@ fn mapper_init_mmc1_horizontal_vs_vertical_control_bits() {
}
#[test]
fn mapper_init_uxrom_emits_label_and_nothing_else() {
fn mapper_init_uxrom_emits_label_and_seeds_zp_bank_current() {
// UxROM powers up with bank 0 at $8000 and the last bank fixed
// at $C000 — exactly what the NEScript runtime expects. All we
// need is a marker label so debuggers can find the (empty)
// init span.
// at $C000, so apart from a marker label there's no mapper-
// specific init to do — *but* the runtime now seeds
// ZP_BANK_CURRENT with the fixed bank index so the
// banked-call trampoline knows which bank to restore on the
// way out. The seed is a 2-instruction LDA #imm / STA $10
// pair appended after the marker label.
let init = gen_mapper_init(Mapper::UxROM, Mirroring::Horizontal, 3);
assert_eq!(init.len(), 1);
assert!(
matches!(&init[0].mode, AM::Label(n) if n == "__uxrom_init"),
"UxROM init should emit just the marker label",
"UxROM init should still start with the marker label",
);
assert_eq!(
init.len(),
3,
"UxROM init should be marker + LDA #fixed + STA ZP_BANK_CURRENT"
);
assert_eq!(init[1].opcode, LDA);
assert!(
matches!(init[1].mode, AM::Immediate(2)),
"fixed bank index for 3 banks is 2"
);
assert_eq!(init[2].opcode, STA);
assert!(matches!(init[2].mode, AM::ZeroPage(addr) if addr == ZP_BANK_CURRENT));
}
#[test]
@ -763,17 +777,18 @@ fn bank_select_assembles_for_every_mapper() {
}
#[test]
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. 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);
fn trampoline_switches_target_then_restores_caller() {
// A trampoline must save the caller's bank from
// ZP_BANK_CURRENT, switch to the target, call the entry, then
// restore the saved value. We check the immediate loaded
// (target bank), the PHA/PLA pair around the body, and the
// JSR sequence.
let t = gen_bank_trampoline("__tramp_helper", "__ir_fn_helper", 0);
// First instruction is the trampoline label.
assert!(matches!(&t[0].mode, AM::Label(n) if n == "__tramp_helper"));
// Extract the sequence of immediate loads.
// The only LDA immediate is the target bank itself — the
// restore path uses PLA, not a hardcoded LDA, so the caller's
// bank can be anything.
let imms: Vec<u8> = t
.iter()
.filter_map(|i| {
@ -785,8 +800,29 @@ fn trampoline_switches_target_then_restores_fixed() {
None
})
.collect();
assert_eq!(imms, vec![0, 3], "trampoline should load target then fixed");
// And two JSRs to __bank_select, plus one JSR to the entry.
assert_eq!(
imms,
vec![0],
"trampoline must load only the target bank as an immediate"
);
// The trampoline must read ZP_BANK_CURRENT into A then PHA
// (save), and later PLA (restore).
let reads_current = t.iter().any(|i| {
i.opcode == LDA && matches!(i.mode, AM::ZeroPage(addr) if addr == ZP_BANK_CURRENT)
});
assert!(
reads_current,
"trampoline must LDA ZP_BANK_CURRENT to capture the caller's bank"
);
let pushes = t.iter().filter(|i| i.opcode == PHA).count();
let pops = t.iter().filter(|i| i.opcode == PLA).count();
assert_eq!(
(pushes, pops),
(1, 1),
"trampoline must have exactly one PHA / PLA pair"
);
// And two JSRs to __bank_select, plus one JSR to the entry —
// dispatch order is still target-first, restore-last.
let jsrs: Vec<&str> = t
.iter()
.filter_map(|i| {
@ -801,7 +837,7 @@ fn trampoline_switches_target_then_restores_fixed() {
assert_eq!(
jsrs,
vec!["__bank_select", "__ir_fn_helper", "__bank_select"],
"trampoline JSRs must dispatch in the correct order"
"trampoline JSRs must dispatch target → entry → restore"
);
// Final instruction returns to caller.
assert_eq!(t.last().unwrap().opcode, RTS);
@ -814,10 +850,58 @@ fn trampoline_label_uses_caller_supplied_name() {
// 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);
let t = gen_bank_trampoline("__tramp_big_helper", "__ir_fn_big_helper", 1);
assert!(matches!(&t[0].mode, AM::Label(n) if n == "__tramp_big_helper"));
}
#[test]
fn mapper_init_seeds_zp_bank_current_with_fixed_bank_index() {
// The trampoline reads ZP_BANK_CURRENT to decide which bank
// to switch back to after a cross-bank call. For fixed-bank
// callers that have never explicitly switched banks, the
// value must point at the fixed bank — otherwise the very
// first cross-bank call from the fixed bank would restore
// bank 0 (the RAM-clear default) at $8000, breaking the
// pre-banked-banked semantics that other examples rely on.
//
// For UxROM with 6 banks, the fixed bank is index 5.
let init = gen_mapper_init(Mapper::UxROM, Mirroring::Horizontal, 6);
let mut found_seed = false;
let mut last_imm: Option<u8> = None;
for inst in &init {
if inst.opcode == LDA {
if let AM::Immediate(v) = inst.mode {
last_imm = Some(v);
}
}
if inst.opcode == STA {
if let AM::ZeroPage(addr) = inst.mode {
if addr == ZP_BANK_CURRENT && last_imm == Some(5) {
found_seed = true;
break;
}
}
}
}
assert!(
found_seed,
"gen_mapper_init must seed ZP_BANK_CURRENT with the fixed bank index (5 here)"
);
}
#[test]
fn nrom_mapper_init_does_not_seed_zp_bank_current() {
// NROM has no banks at all, so seeding ZP_BANK_CURRENT would
// both waste a couple of bytes and cause the existing NROM
// example ROMs (every flat-mapper sample) to byte-shift.
// Verify the init stays empty for NROM.
let init = gen_mapper_init(Mapper::NROM, Mirroring::Horizontal, 1);
assert!(
init.is_empty(),
"NROM mapper init must remain empty: {init:?}"
);
}
#[test]
fn uxrom_bank_table_is_256_bytes_of_sequential_values() {
// The bus-conflict table must contain bytes 0..=255 in order