seam: a bank-aware ROM read beside the bus read

MemoryReader::read8 reads the CPU bus, where banks 1 and up are whichever bank
the cartridge last switched to, so a table in another bank could only be read by
writing the mapper register -- and the joypad is the only write this workspace
makes into a running game. read_rom takes a bank and a CPU address and reads the
cartridge image the process already holds instead. Defaulted to None, so every
reader without a cartridge behind it narrows rather than guesses.
This commit is contained in:
claude 2026-09-22 03:32:19 +00:00
parent 320b05ecee
commit d75b7320ea
2 changed files with 60 additions and 0 deletions

View file

@ -20,12 +20,37 @@ use crate::ratchet::RecoveryPolicy;
/// equivalent of the prototype's `MemoryReader` interface.
pub trait MemoryReader {
fn read8(&mut self, address: u16) -> u8;
/// One byte of a ROM bank, by bank number rather than off the CPU bus.
///
/// [`MemoryReader::read8`] reads the bus, where banks 1 and up are whichever
/// bank the cartridge's last switch left mapped -- so a table in bank 3 is
/// unreadable through it, and the only way to make it readable would be to
/// *write* the mapper's bank register. The joypad is the only write this
/// workspace makes into a running game (`docs/design/macros.md` section 12),
/// so this reads the cartridge image the process already holds instead: the
/// same bytes, addressed the way the disassembly addresses them.
///
/// `address` is a CPU address: below `$4000` it is bank 0 whatever `bank`
/// says, and `$4000..$8000` is the banked window. Anything else, and any
/// offset past the end of the image, is `None`.
///
/// The default is `None`: a reader with no cartridge behind it cannot answer,
/// and every caller of this is written to narrow rather than guess when it
/// does not (`docs/design/macros-wram.md`, the whole-map grid).
fn read_rom(&mut self, _bank: u8, _address: u16) -> Option<u8> {
None
}
}
impl MemoryReader for &mut dyn MemoryReader {
fn read8(&mut self, address: u16) -> u8 {
(**self).read8(address)
}
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
(**self).read_rom(bank, address)
}
}
/// One reward payout in one frame.

View file

@ -26,6 +26,10 @@ pub const CPU_TICKS_PER_SECOND: u64 = 4_194_304;
/// so there is no `NEW_FRAME` event to wait for yet.
const MAX_FRAME_ATTEMPTS: u32 = 120;
/// Bytes in one ROM bank (`$4000`), which is how [`Emulator::read_rom_bank`]
/// turns a bank number and a CPU address into an offset in the cartridge image.
const ROM_BANK_BYTES: usize = 0x4000;
/// Audio frequency flysim runs the emulator at. binjgb resamples internally to
/// whatever is requested, so this is only a default.
pub const DEFAULT_AUDIO_FREQUENCY: u32 = 48_000;
@ -135,6 +139,13 @@ impl FrameCache {
/// thread. It is deliberately not `Sync`.
pub struct Emulator {
gb: *mut ffi::FlyGb,
/// The cartridge image, for [`MemoryReader::read_rom`].
///
/// The shim owns its own padded copy behind the handle and does not hand it
/// back, so this is a second one. It is read-only from here: nothing in this
/// workspace writes a ROM byte, and the bank-addressed read is the only
/// reason it is kept.
rom: std::sync::Arc<[u8]>,
rom_sha256: [u8; 32],
audio_frequency: u32,
/// Raw binjgb samples drained since the last [`Emulator::take_audio`].
@ -167,6 +178,7 @@ impl Emulator {
debug_assert_eq!(unsafe { ffi::fly_gb_frame_buffer_size() }, FRAMEBUFFER_LEN);
Ok(Self {
gb,
rom: rom.into(),
rom_sha256: Sha256::digest(rom).into(),
audio_frequency,
pending_audio: Vec::new(),
@ -242,6 +254,25 @@ impl Emulator {
unsafe { ffi::fly_gb_read_mem(self.gb, address) }
}
/// One byte of ROM bank `bank`, from the cartridge image rather than the bus.
///
/// `address` is a CPU address: `$0000..$4000` is bank 0 whatever `bank` says
/// (that is what "always mapped" means) and `$4000..$8000` is the banked
/// window. `None` for any other address and for an offset past the end of the
/// image, which is what a bank a smaller cartridge does not have reads as.
/// No bank register is written and the emulator's state does not move: this
/// is a read of bytes the process already owns.
pub fn read_rom_bank(&self, bank: u8, address: u16) -> Option<u8> {
let offset = match address {
0x0000..=0x3fff => usize::from(address),
0x4000..=0x7fff => {
usize::from(bank) * ROM_BANK_BYTES + usize::from(address) - ROM_BANK_BYTES
}
_ => return None,
};
self.rom.get(offset).copied()
}
/// Sample rate of the raw buffer, as binjgb configured it.
pub fn audio_frequency(&self) -> u32 {
self.audio_frequency
@ -364,6 +395,10 @@ impl MemoryReader for Emulator {
fn read8(&mut self, address: u16) -> u8 {
self.read_wram(address)
}
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
self.read_rom_bank(bank, address)
}
}
#[cfg(test)]