Skip to main content

Crate bank

Crate bank 

Source
Available on crate feature bank only.
Expand description

Compile-time-safe ROM bank switching for the Game Boy.

gb-bank is the user-facing front end of the banking toolchain: the runtime types, plus the macros that turn ordinary functions and statics into bank-safe ones.

§The problem

A Game Boy cartridge maps one 16 KiB ROM bank at a time into the 0x4000..0x8000 window. Anything in another bank is unaddressable until it is switched in, and reading it anyway yields whichever bytes happen to be mapped, with nothing to fault on the way.

This crate makes that a type error. Reaching banked code or data requires a token proving its bank is mapped, and the only way to get one is to perform the switch.

§Getting started

§Put code in a bank

One module is one bank group. bank::module!() declares it, #[bank] marks what goes in the bank.

mod sound {
    use gb_bank::*;
    bank::module!();

    #[bank]
    pub static NOTES: [u8; 4] = [60, 62, 64, 65];

    #[bank]
    pub fn play(i: u8) -> u8 {
        NOTES.local()[i as usize]
    }
}

gb-bank-pack decides at link time which ROM bank the module lands in. Naming one yourself is optional (see Bank layout).

§Call it

Your entry point is #[bank::main], which lives in bank 0 and is always mapped. It stands in for #[gb_rt::entry], so the signature is the one that macro wants, fn() -> !.

#[bank::main]
pub fn main() -> ! {
    loop {
        let note = sound::play(0).drive();
    }
}

sound::play(0) does not run the body. It captures the call, and .drive() runs it: switch into sound’s bank, call, switch back.

§Read its data

#[bank] static gives you a Far pointer. Holding one is always fine; reading through it is what needs the bank mapped.

#[bank::main]
pub fn main() -> ! {
    let first = sound::NOTES.there(|n| n[0]);
    loop {}
}

.there() switches, lends your closure &[u8; 4], and switches back. Whatever the closure returns is copied out, so it must not be a pointer into the bank you just left; that is what BankSafe checks.

§Inside a banked function

A #[bank] body has one restriction, and it comes from the hardware: its own code sits in the switchable window, so it cannot perform a switch. The next instruction fetch would come from whatever bank replaced it.

Calls are fine, because the switch happens in a bank-0 trampoline rather than in your function:

#[bank]
pub fn tick() -> u8 {
    NOTES.local()[0]            // same bank: no switch at all
        + other::helper().drive() // another bank: switches in bank 0
}

.there() and scope are not, because they run your closure while the other bank is mapped. Both are a compile error in a #[bank] body. Route them through a #[bank::zero] helper, which lives in bank 0 and can be called from any bank:

#[bank::zero]
fn notes() -> [u8; 4] {
    sound::NOTES.there(|n| *n)   // bank 0: allowed
}

#[bank]
pub fn tick() -> u8 {
    let n = notes().drive();     // copied out; this bank is mapped again
    n[0].wrapping_add(n[3])
}

§Runtime dispatch

A Far<T, G> carries its bank in the type, so pointers into different banks are different types. erase drops the type for a runtime number, letting a table hold entries from several banks.

let table: [DynFar<fn(u8) -> u8>; 2] = [
    far!(enemy::ai).erase(),
    far!(hud_tick).erase(),      // a bank-0 helper works here too
];
table[i].invoke(state)

§Where the switch happens

Six operations reach banked code or data. Which one you can use depends on where you are; what it costs depends on whether it switches. The ones that do switch restore the caller’s bank on the way out.

switchesusable from
localnoany body, with a token for that bank
nearnoany body, with a token for that bank
driveyesany body
invokeyesany body
thereyesbank 0 only
scopeyesbank 0 only

drive and invoke run one whole function across the switch, so no code of yours is unmapped while it happens and they compile to a bank-0 trampoline. there and scope lend your closure instead, which is why they need to be in bank 0 to begin with.

local and near take a token for the exact group, so they never switch, and reaching the wrong bank through one is a type error. Use them to batch: open one scope and work inside it.

let r = scope(|b| {
    let x = sound::play(v).near(b);   // already in sound's bank
    sound::play(x).near(b)            // still there: no second switch
});

A switch is also elided when it would be a no-op: when the target group is the caller’s own, and when either side is the always-mapped GroupZero.

§The model

A Group is the compile-time identity of one bank, a zero-sized type that bank::module!() generates per module. A Bank<G> is a zero-sized token, and holding one witnesses that G’s bank is mapped right now. The token is !Send, not Clone, and mintable only through scope or the unsafe assume, so it cannot be fabricated.

scope is the switch primitive everything else is built on. It also takes an Anchor: a witness that the calling code is in bank 0, which is what makes it safe to run a closure while another bank is mapped. #[bank::main] and #[bank::zero] bodies hold one; a #[bank] body does not.

A Far<T, G> splits the address from the access. The address is a plain Copy value that survives any switch; reading it borrows a Bank<G>. Since a switch needs that token by &mut, a reference into a bank cannot outlive the switch away from it.

A Warp is a call captured but not yet run, so that the switch can take the caller’s token at the point it is driven.

§Prior art

Bank and Far follow GhostCell, which keeps a permission token apart from the data it guards, tied by a brand. gb-bank brands with a group type where GhostCell uses a lifetime, because a bank’s identity is fixed and reused across many functions, which a per-scope lifetime cannot express.

Warp follows Future: an async fn returns something inert until .await, and a banked call is deferred for the same reason, except that drive takes a token rather than an executor.

§The macros

  • bank::module!() declares the enclosing module as a bank group. bank::module!(N) pins it to bank N instead of auto-assigning.
  • bank::inherit!() in a submodule folds it into its parent module’s group (super is a keyword, hence the name).
  • #[bank] on a fn rewrites it to return impl Warp; on a static it exposes a Far. Also works on an impl or trait. The return type must be BankSafe.
  • #[bank::main] marks the entry point in bank 0. It wraps #[gb_rt::entry], so that attribute must not be applied as well.
  • #[bank::zero] marks a bank-0 helper callable from any bank: it forwards the caller’s token, so its own banked calls restore the caller’s bank.
  • far! takes a Far to a banked function for dispatch tables. It works on a #[bank::zero] helper too, so a table can mix the two.

§Sugar

Inside any #[bank] / #[bank::main] / #[bank::zero] body the macro injects the ambient bank token (and, in a bank-0 body, an Anchor) as implicit leading arguments, so they never appear in your code:

you writethe macro emitswhere
enemy::ai(s).drive()enemy::ai(s).drive(&mut __bank)any body
enemy::ai(s).near()enemy::ai(s).near(&mut __bank)any body
table[i].invoke(s)table[i].invoke(&mut __bank, (s,))any body
NOTES.local()NOTES.local(&__bank)any body
NOTES.there(|t| ..)NOTES.there(__anchor, &mut __bank, |t| ..)bank 0 only
scope(|b| ..)scope(__anchor, &mut __bank, |b| ..)bank 0 only

A scope rebinds the ambient token to its own closure parameter b, so a nested scope or a .drive() / .near() / .local() inside it threads b, not the outer token; the Anchor, being Copy, flows in automatically. The ambient __bank is hidden and cannot be named, but b can.

§Threading is by method name, not type

The rewrite runs before type checking, so it matches on the method name alone and threads the token into every .drive() / .near() / .local() / .invoke() / .there(..) (and scope(..)) in the body, whatever the receiver. The names are deliberately uncommon. If one does collide with an unrelated method, call that one as Type::method(recv, args): a path call is not method-call sugar, so the macro leaves it alone.

§Bank layout

By default gb-bank-pack bin-packs the banked modules into 16 KiB banks. Pin each module with bank::module!(N) for a deterministic one-module-per-bank layout, so a call from one module to another is always a genuine switch.

mod audio   { use gb_bank::*; bank::module!(1); /* ... */ }
mod physics {
    use gb_bank::*;
    bank::module!(2);
    pub mod trig { use gb_bank::*; bank::inherit!(); /* shares physics: bank 2 */ }
}

Bank 0 is the always-mapped region and cannot be pinned to. With no MBC at all the whole 32 KiB is fixed, and a build for such a cartridge carries no switching code at all.

§Panics, unwinding, and interrupts

The bank restore in scope (and the Far call / borrow paths) runs after the closure returns, so an unwinding panic would skip it and leave the wrong bank mapped. This is sound on the Game Boy because the target aborts on panic (there is no unwinder), so a panic never resumes into a stale-bank token. These types are not designed to be unwind-safe on a hosted, unwinding target.

The safety model also assumes nothing changes the mapped bank behind the token’s back. An interrupt handler that switches banks (e.g. to read banked data) must save current_bank on entry and restore it before returning, so the interrupted code resumes with the bank its live token still claims is mapped. An ISR that is not bank-transparent breaks the invariant, just like a raw switch_bank not paired with a matching restore.

§Cartridges

Which banks exist at all is the cartridge’s business. cargo-gb reads the type from header.toml and refuses a build that needs more banks than it can map.

CartridgeSwitchable banksROMwith wide_banks = true
ROM ONLYnone32 KiB
MBC11-31512 KiB1-127 minus 0x20, 0x40, 0x60, 2 MiB
MBC21-15256 KiB
MBC31-1272 MiB
MBC51-2554 MiB1-511, 8 MiB
MBC71-1272 MiB

The ranges without wide_banks are what a single write to the register at 0x2000 selects, and that is all the runtime writes by default. wide_banks brings in the cartridge’s second bank register, which only MBC1 and MBC5 have; see BankNumber for what that costs. The three banks MBC1 has to skip are the ones whose low five bits are zero, which it reads as bank 1, and its upper bits only reach the ROM in banking mode 0, so selecting mode 1 for RAM banking gives up banks 32 and above.

MBC6, MMM01, HuC1, HuC3, and the Pocket Camera and TAMA5 mappers are not built in for now. A custom cartridge that selects banks its own way implements Mapper and names it with set_mapper!.

Modules§

bank
The banking attribute macros, namespaced as bank::*.
prelude
Everything needed to write banked code in one import.

Macros§

far
Take a far pointer to a banked function, for heterogeneous dispatch.
inherit
Declare a submodule as part of its parent module’s bank group.
module
Declare the enclosing module as a ROM bank group.
set_mapper
Name the Mapper this program’s cartridge uses.

Structs§

Anchor
Proof that the holder runs in bank 0 (the always-mapped region), and so may run a closure across a bank switch via scope / there.
Bank
A witness that group G’s bank is currently mapped.
BankNumber
A ROM bank number, held as a u8 or, under gb_wide_bank="mbc5", a u16.
DynFar
A far pointer whose bank is known only at runtime: a group-erased Far.
Far
A pointer to a T living in group G’s bank.
GroupZero
Bank 0: the always-mapped region.

Traits§

BankSafe
A value safe to carry across a bank switch: it embeds no pointer to banked code that the switch would unmap.
FarCall
A far pointer that can be called across a bank boundary.
FarWith
Borrowing a far pointer’s data across a bank boundary.
Group
The compile-time identity of a bank group.
Mapper
How a cartridge maps a bank into 0x4000..0x8000.
Warp
A deferred banked call: a function applied to its arguments, not yet run.

Functions§

current_bank
Read the currently mapped bank from the software shadow.
scope
Enter group G’s bank for the duration of f, then restore the caller (C).
switch_bank
Switch the active ROM bank.

Attribute Macros§

bank
Mark a function, static, impl block, or trait as banked.
main
Mark the entry point (bank 0, the root of the call stack).
zero
Mark a bank-0 helper that drives banked code.