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.
| switches | usable from | |
|---|---|---|
local | no | any body, with a token for that bank |
near | no | any body, with a token for that bank |
drive | yes | any body |
invoke | yes | any body |
there | yes | bank 0 only |
scope | yes | bank 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 bankNinstead of auto-assigning.bank::inherit!()in a submodule folds it into its parent module’s group (superis a keyword, hence the name).#[bank]on afnrewrites it to returnimpl Warp; on astaticit exposes aFar. Also works on animplortrait. The return type must beBankSafe.#[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 aFarto 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 write | the macro emits | where |
|---|---|---|
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.
| Cartridge | Switchable banks | ROM | with wide_banks = true |
|---|---|---|---|
| ROM ONLY | none | 32 KiB | |
| MBC1 | 1-31 | 512 KiB | 1-127 minus 0x20, 0x40, 0x60, 2 MiB |
| MBC2 | 1-15 | 256 KiB | |
| MBC3 | 1-127 | 2 MiB | |
| MBC5 | 1-255 | 4 MiB | 1-511, 8 MiB |
| MBC7 | 1-127 | 2 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
Mapperthis 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. - Bank
Number - A ROM bank number, held as a
u8or, undergb_wide_bank="mbc5", au16. - DynFar
- A far pointer whose bank is known only at runtime: a group-erased
Far. - Far
- A pointer to a
Tliving in groupG’s bank. - Group
Zero - Bank 0: the always-mapped region.
Traits§
- Bank
Safe - 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 off, then restore the caller (C). - switch_
bank ⚠ - Switch the active ROM bank.