feat(timing): implement nominal Game Pak opcode prefetch

This commit is contained in:
0xrsydn 2026-10-06 20:32:54 +07:00
commit 8f22092e06
25 changed files with 1062 additions and 124 deletions

View file

@ -229,10 +229,16 @@ impl Cpu {
/// Execute one ARM instruction or one 16-bit Thumb instruction. /// Execute one ARM instruction or one 16-bit Thumb instruction.
/// Each half of a Thumb BL is a separate step. /// Each half of a Thumb BL is a separate step.
/// This CPU-only API does not execute DMA, advance clocks, or sample device interrupts. /// This CPU-only API does not execute DMA, advance clocks, or sample device interrupts.
/// Enabled opcode prefetch still tracks nominal bus time, as in step_timed.
/// It records HALTCNT writes but does not enforce HALT. Use Machine::step for integration. /// It records HALTCNT writes but does not enforce HALT. Use Machine::step for integration.
/// Unsupported operations and memory errors leave CPU state unchanged. /// Unsupported operations and memory errors leave CPU state unchanged.
/// These errors are development diagnostics, not emulated CPU exceptions. /// These errors are development diagnostics, not emulated CPU exceptions.
pub fn step(&mut self, memory: &mut Memory) -> Result<(), CpuError> { pub fn step(&mut self, memory: &mut Memory) -> Result<(), CpuError> {
// Prefetch timing state must agree across CPU-only and timed calls.
// Compute and discard nominal costs without advancing any device clocks.
if memory.waitcnt() & 0x4000 != 0 {
return self.step_timed(memory).map(|_| ());
}
let instruction = self.fetch(memory)?; let instruction = self.fetch(memory)?;
self.execute_fetched(instruction, memory) self.execute_fetched(instruction, memory)
} }

View file

@ -134,17 +134,40 @@ fn check_pair(thumb: bool, instruction: u32, breaks: bool) {
let extra = if thumb { 0 } else { s }; let extra = if thumb { 0 } else { s };
let mut cpu = cpu.clone(); let mut cpu = cpu.clone();
// Cold startup keeps the existing nominal S policy. // Cold startup keeps the existing nominal S policy.
let first_timing = cpu.step_timed(&mut memory).unwrap();
assert_eq!( assert_eq!(
cpu.step_timed(&mut memory).unwrap().code_cycles, first_timing.code_cycles,
s + extra, s + extra,
"first fetch for {instruction:08x}" "first fetch for {instruction:08x}"
); );
assert_eq!(
cpu.pipeline.as_ref().unwrap().next_access,
if breaks {
AccessKind::NonSequential
} else {
AccessKind::Sequential
}
);
// RAM/internal work advances the opcode queue, independently of
// the CPU's N request. The Thumb literal load instead cancels it.
let expected = if prefetch != 0 && !(thumb && instruction == 0x4800) {
(s + extra)
.saturating_sub(first_timing.data_cycles + first_timing.internal_cycles)
.max(1)
} else if breaks {
n + extra
} else {
s + extra
};
assert_eq!( assert_eq!(
cpu.step_timed(&mut memory).unwrap().code_cycles, cpu.step_timed(&mut memory).unwrap().code_cycles,
if breaks { n + extra } else { s + extra }, expected,
"following fetch for {instruction:08x}" "following fetch for {instruction:08x}"
); );
// Excess queued work can also accelerate a later fetch.
if prefetch == 0 {
assert_eq!(cpu.step_timed(&mut memory).unwrap().code_cycles, s + extra); assert_eq!(cpu.step_timed(&mut memory).unwrap().code_cycles, s + extra);
}
assert_eq!(memory.cycles(), 0); assert_eq!(memory.cycles(), 0);
} }
} }

View file

@ -31,6 +31,13 @@ impl Cpu {
self.pipeline = None; self.pipeline = None;
} }
/// Timing metadata cannot survive a cold or mismatched instruction pipeline.
pub(super) fn has_retained_pipeline(&self) -> bool {
self.pipeline.as_ref().is_some_and(|pipe| {
pipe.pc == self.pc() && pipe.instruction_set == self.instruction_set
})
}
/// Cold/debugger entry keeps the existing nominal S startup policy. /// Cold/debugger entry keeps the existing nominal S startup policy.
/// A mismatched PC/state cannot reuse another sequence's access kind. /// A mismatched PC/state cannot reuse another sequence's access kind.
pub(super) fn next_fetch_kind(&self) -> AccessKind { pub(super) fn next_fetch_kind(&self) -> AccessKind {

View file

@ -59,7 +59,7 @@ impl Cpu {
let fetched = self.fetch(memory)?; let fetched = self.fetch(memory)?;
let code_kind = memory.cpu_code_kind(self.next_fetch_kind()); let code_kind = memory.cpu_code_kind(self.next_fetch_kind());
// Source timing precedes data accesses and their WAITCNT writes. // Source timing precedes data accesses and their WAITCNT writes.
memory.begin_cpu_timing(); memory.begin_cpu_timing(fetched.fill.is_some());
memory.record_code_access( memory.record_code_access(
fetched.lookahead.address(), fetched.lookahead.address(),
self.instruction_set.access_width(), self.instruction_set.access_width(),
@ -75,12 +75,13 @@ impl Cpu {
/// A missing discarded fetch cannot prevent entry or become a current-slot error. /// A missing discarded fetch cannot prevent entry or become a current-slot error.
pub(crate) fn take_irq_timed(&mut self, memory: &mut Memory) -> Option<StepTiming> { pub(crate) fn take_irq_timed(&mut self, memory: &mut Memory) -> Option<StepTiming> {
let state = self.instruction_set; let state = self.instruction_set;
let cold = !self.has_retained_pipeline();
let address = self.pc().wrapping_add(2 * state.width()); let address = self.pc().wrapping_add(2 * state.width());
let kind = memory.cpu_code_kind(self.next_fetch_kind()); let kind = memory.cpu_code_kind(self.next_fetch_kind());
if !self.take_interrupt(memory.irq_pending(), false) { if !self.take_interrupt(memory.irq_pending(), false) {
return None; return None;
} }
memory.begin_cpu_timing(); memory.begin_cpu_timing(cold);
let fetch = memory.fetch_instruction(address, state); let fetch = memory.fetch_instruction(address, state);
memory.record_code_access(fetch.address(), state.access_width(), kind); memory.record_code_access(fetch.address(), state.access_width(), kind);
memory.discarded_cpu_fetch(&fetch); memory.discarded_cpu_fetch(&fetch);

View file

@ -156,7 +156,8 @@ impl Machine {
/// generate FIQ. IRQ entry consumes a separate step and does not clear IF. /// generate FIQ. IRQ entry consumes a separate step and does not clear IF.
/// CPU/DMA work uses nominal costs; HALT advances exactly to the next device event. /// CPU/DMA work uses nominal costs; HALT advances exactly to the next device event.
/// CPU writes take effect before this bulk device update. Within-instruction /// CPU writes take effect before this bulk device update. Within-instruction
/// register timing, prefetch, and IRQ synchronization delays are not modeled. /// register timing and IRQ synchronization delays are not modeled. Opcode
/// prefetch follows ordered nominal CPU/DMA costs, not per-cycle arbitration.
/// CPU/DMA diagnostics leave CPU, devices, and the clock unchanged for this step. /// CPU/DMA diagnostics leave CPU, devices, and the clock unchanged for this step.
/// Row-capture diagnostics are deferred to Memory::present_frame instead. /// Row-capture diagnostics are deferred to Memory::present_frame instead.
pub fn step(&mut self) -> Result<StepKind, MachineError> { pub fn step(&mut self) -> Result<StepKind, MachineError> {
@ -173,7 +174,7 @@ impl Machine {
idle_cycles: self.memory.next_event_cycles(), idle_cycles: self.memory.next_event_cycles(),
..StepTiming::default() ..StepTiming::default()
}; };
self.memory.advance_cycles(timing.total()); self.memory.advance_halt_cycles(timing.total());
self.last_timing = timing; self.last_timing = timing;
return Ok(StepKind::HaltIdle); return Ok(StepKind::HaltIdle);
} }

View file

@ -9,6 +9,8 @@ mod iwram_bus;
#[cfg(test)] #[cfg(test)]
mod iwram_history_tests; mod iwram_history_tests;
#[cfg(test)] #[cfg(test)]
mod prefetch_tests;
#[cfg(test)]
mod timing_event_tests; mod timing_event_tests;
pub(crate) use fetch::InstructionFetch; pub(crate) use fetch::InstructionFetch;
use iwram_bus::IwramBus; use iwram_bus::IwramBus;
@ -19,7 +21,7 @@ use crate::{
dma::{DmaError, DMA_BASE, DMA_END}, dma::{DmaError, DMA_BASE, DMA_END},
input::Buttons, input::Buttons,
io::{Io, HALTCNT, KEYINPUT, POSTFLG}, io::{Io, HALTCNT, KEYINPUT, POSTFLG},
timing::{bus_cycles, AccessKind, AccessWidth, CpuTiming, StepTiming}, timing::{AccessKind, AccessWidth, CpuTiming, Prefetch, StepTiming},
video::{self, sprites::pipeline, Framebuffer, VideoError}, video::{self, sprites::pipeline, Framebuffer, VideoError},
}; };
@ -92,6 +94,7 @@ pub struct Memory {
io: Io, io: Io,
cycles: u64, cycles: u64,
cpu_timing: Cell<Option<CpuTiming>>, cpu_timing: Cell<Option<CpuTiming>>,
gamepak_prefetch: Prefetch,
#[cfg(test)] #[cfg(test)]
last_cpu_timing: Option<CpuTiming>, last_cpu_timing: Option<CpuTiming>,
// A completed DMA unit breaks the CPU's next nominal code-access sequence. // A completed DMA unit breaks the CPU's next nominal code-access sequence.
@ -119,6 +122,7 @@ impl Memory {
io: Io::default(), io: Io::default(),
cycles: 0, cycles: 0,
cpu_timing: Cell::new(None), cpu_timing: Cell::new(None),
gamepak_prefetch: Prefetch::default(),
#[cfg(test)] #[cfg(test)]
last_cpu_timing: None, last_cpu_timing: None,
cpu_resume_nonsequential: false, cpu_resume_nonsequential: false,
@ -150,6 +154,8 @@ impl Memory {
/// Advance devices and optional row capture. Multiple unserviced DMA requests /// Advance devices and optional row capture. Multiple unserviced DMA requests
/// coalesce. No CPU or DMA work is executed by this method. STOP freezes all /// coalesce. No CPU or DMA work is executed by this method. STOP freezes all
/// progress: supplied cycles are ignored until an external wake condition. /// progress: supplied cycles are ignored until an external wake condition.
/// This device-only API does not advance opcode prefetch. CPU/DMA/HALT steps
/// account for their own cartridge-bus time before updating device clocks.
pub fn advance_cycles(&mut self, cycles: u32) { pub fn advance_cycles(&mut self, cycles: u32) {
if !self.stopped() { if !self.stopped() {
self.advance_running_cycles(cycles); self.advance_running_cycles(cycles);
@ -337,11 +343,16 @@ impl Memory {
} else { } else {
AccessKind::Sequential AccessKind::Sequential
}; };
let mut prefetch = self.gamepak_prefetch;
prefetch.configure(self.waitcnt());
let internal_cycles = if transfer.first { 2 } else { 0 };
// Keep the existing whole-unit DMA startup model; no sub-instruction arbitration.
prefetch.advance(internal_cycles);
let timing = StepTiming { let timing = StepTiming {
code_cycles: 0, code_cycles: 0,
data_cycles: bus_cycles(self.waitcnt(), transfer.source, transfer.width, kind) data_cycles: prefetch.data(transfer.source, transfer.width, kind)
+ bus_cycles(self.waitcnt(), transfer.destination, transfer.width, kind), + prefetch.data(transfer.destination, transfer.width, kind),
internal_cycles: if transfer.first { 2 } else { 0 }, internal_cycles,
idle_cycles: 0, idle_cycles: 0,
}; };
match transfer.width { match transfer.width {
@ -350,6 +361,9 @@ impl Memory {
AccessWidth::Byte => unreachable!("DMA is never byte-wide"), AccessWidth::Byte => unreachable!("DMA is never byte-wide"),
} }
.map_err(map_error)?; .map_err(map_error)?;
// Only a successful unit commits queue progress. A WAITCNT destination can reset it.
prefetch.configure(self.waitcnt());
self.gamepak_prefetch = prefetch;
// DMA runs before the resumed CPU fetch. Only actual IWRAM accesses drive // DMA runs before the resumed CPU fetch. Only actual IWRAM accesses drive
// its local lanes, in read/write order. Channel halfword duplication is not // its local lanes, in read/write order. Channel halfword duplication is not
// a full IWRAM bus write. Failed units never reach these history commits. // a full IWRAM bus write. Failed units never reach these history commits.
@ -500,7 +514,7 @@ impl Memory {
) )
} }
/// Current Game Pak wait-state control. Prefetch is not implemented. /// Current Game Pak wait-state control, including nominal opcode prefetch enable.
pub fn waitcnt(&self) -> u16 { pub fn waitcnt(&self) -> u16 {
self.io.waitcnt() self.io.waitcnt()
} }
@ -515,9 +529,21 @@ impl Memory {
} }
} }
pub(crate) fn begin_cpu_timing(&self) { pub(crate) fn begin_cpu_timing(&self, cold: bool) {
debug_assert!(self.cpu_timing.get().is_none()); debug_assert!(self.cpu_timing.get().is_none());
self.cpu_timing.set(Some(CpuTiming::new(self.waitcnt()))); let mut prefetch = self.gamepak_prefetch;
if cold {
prefetch.clear();
}
self.cpu_timing
.set(Some(CpuTiming::new(self.waitcnt(), prefetch)));
}
pub(crate) fn advance_halt_cycles(&mut self, cycles: u32) {
if !self.stopped() {
self.gamepak_prefetch.advance(cycles);
}
self.advance_cycles(cycles);
} }
pub(crate) fn end_cpu_timing(&mut self, succeeded: bool) -> StepTiming { pub(crate) fn end_cpu_timing(&mut self, succeeded: bool) -> StepTiming {
@ -528,6 +554,7 @@ impl Memory {
if !succeeded { if !succeeded {
return StepTiming::default(); return StepTiming::default();
} }
self.gamepak_prefetch = trace.prefetch;
#[cfg(test)] #[cfg(test)]
{ {
self.last_cpu_timing = Some(trace); self.last_cpu_timing = Some(trace);
@ -669,6 +696,15 @@ impl Memory {
} }
self.io.write8(byte_address, byte); self.io.write8(byte_address, byte);
} }
if address & !3 == crate::io::WAITCNT {
// CPU changes are speculative with their timing transaction; host/DMA changes are immediate.
if let Some(mut trace) = self.cpu_timing.get() {
trace.prefetch.configure(self.waitcnt());
self.cpu_timing.set(Some(trace));
} else {
self.gamepak_prefetch.configure(self.waitcnt());
}
}
return Ok(()); return Ok(());
} }
if N == 1 && address >> 24 == 0x07 { if N == 1 && address >> 24 == 0x07 {

View file

@ -142,7 +142,7 @@ fn sampling_has_no_timing_or_history_side_effects_and_never_uses_data_fallback()
bios[8..12].copy_from_slice(&WORD.to_le_bytes()); bios[8..12].copy_from_slice(&WORD.to_le_bytes());
let mut memory = Memory::with_bios(vec![], bios).unwrap(); let mut memory = Memory::with_bios(vec![], bios).unwrap();
memory.cpu_resume_nonsequential = true; memory.cpu_resume_nonsequential = true;
memory.begin_cpu_timing(); memory.begin_cpu_timing(false);
let fetch = memory.fetch_instruction(8, InstructionSet::Arm); let fetch = memory.fetch_instruction(8, InstructionSet::Arm);
memory.fetch_instruction(0x0300_0000, InstructionSet::Thumb); memory.fetch_instruction(0x0300_0000, InstructionSet::Thumb);
assert_eq!(memory.end_cpu_timing(true), StepTiming::default()); assert_eq!(memory.end_cpu_timing(true), StepTiming::default());

View file

@ -0,0 +1,304 @@
//! Original integration checks. Expected cycles are explicit, not computed by the queue.
use super::{timing_event_tests::prepared, *};
use crate::{
cpu::Cpu,
dma::DMA_STRIDE,
io::{HALTCNT, IE, IME, TIMER_BASE, WAITCNT},
machine::{Machine, StepKind},
timing::TimingEvent,
};
const NOP: u32 = 0xe1a0_0000;
const RAM: u32 = 0x0300_1000;
fn enable(memory: &mut Memory) {
memory.write16(WAITCNT, 0x4000).unwrap();
}
fn dma(memory: &mut Memory, source: u32, destination: u32) {
let base = DMA_BASE + 3 * DMA_STRIDE;
memory.write32(base, source).unwrap();
memory.write32(base + 4, destination).unwrap();
memory.write32(base + 8, 0x8400_0001).unwrap();
}
#[test]
fn ram_loads_and_internal_work_reduce_the_following_fetch_despite_cpu_n_kind() {
for (thumb, instruction, address, expected) in [
(false, 0xe591_0000, RAM, 4), // 6 ROM cycles minus RAM 1 and load 1I.
(false, 0xe591_0000, 0x0200_1000, 1), // EWRAM 6 plus load 1I.
(true, 0x6808, RAM, 1), // 3 ROM cycles minus RAM 1 and load 1I.
(false, 0xe1a0_0312, RAM, 5), // Register shift supplies 1I.
(true, 0x4090, RAM, 2), // Thumb register shift supplies 1I.
] {
let (mut cpu, mut memory) = prepared(thumb, &[instruction, NOP], &[(1, address)]);
enable(&mut memory);
cpu.step_timed(&mut memory).unwrap();
assert_eq!(cpu.step_timed(&mut memory).unwrap().code_cycles, expected);
assert!(matches!(memory.last_cpu_timing.unwrap().events()[0],
TimingEvent::Code { kind: AccessKind::NonSequential, cycles, .. } if cycles == expected));
assert_eq!(memory.cycles(), 0);
}
}
#[test]
fn rom_data_cancels_the_queue_and_charges_its_last_cycle_stall() {
// An EWRAM load leaves seven free cycles. The following hit advances one more
// cycle before ROM data cancels the active third halfword.
let (mut cpu, mut memory) = prepared(
false,
&[0xe591_0000, 0xe592_0000, NOP],
&[(1, 0x0200_1000), (2, ROM_START)],
);
enable(&mut memory);
cpu.step_timed(&mut memory).unwrap(); // Seven idle-bus cycles: two entries and one cycle of the third.
let timing = cpu.step_timed(&mut memory).unwrap(); // Hit adds one more cycle: remaining=1.
assert_eq!(timing.code_cycles, 1);
assert_eq!(timing.data_cycles, 9); // Raw N+S=8, cancellation=1.
assert_eq!(timing.internal_cycles, 1);
assert_eq!(cpu.step_timed(&mut memory).unwrap().code_cycles, 8);
}
#[test]
fn branches_can_match_the_head_or_cancel_a_partial_fetch() {
for (instruction, expected) in [(0xea00_0001, 18), (0xea00_0010, 20)] {
let (mut cpu, mut memory) = prepared(false, &[instruction], &[]);
enable(&mut memory);
assert_eq!(cpu.step_timed(&mut memory).unwrap().code_cycles, expected);
assert_eq!(cpu.step_timed(&mut memory).unwrap().code_cycles, 6);
}
let (mut cpu, mut memory) = prepared(false, &[0xe591_f000], &[(1, RAM)]);
memory.write32(RAM, ROM_START + 0x300).unwrap();
enable(&mut memory);
let timing = cpu.step_timed(&mut memory).unwrap();
assert_eq!(timing.code_cycles, 21); // Source 6, cancelled target 9, second target 6.
assert_eq!(timing.data_cycles + timing.internal_cycles, 2);
}
#[test]
fn swi_return_keeps_queue_ownership_separate_from_saved_instruction_state() {
for thumb in [false, true] {
let swi = if thumb { 0xdf00 } else { 0xef00_0000 };
let nop = if thumb { 0x46c0 } else { NOP };
let (mut cpu, mut memory) = prepared(thumb, &[swi, nop], &[]);
// Original one-instruction BIOS handler: MOVS pc,lr restores the saved state.
memory.bios.as_mut().unwrap()[8..12].copy_from_slice(&0xe1b0_f00e_u32.to_le_bytes());
enable(&mut memory);
let state = cpu.instruction_set();
let return_pc = cpu.pc() + if thumb { 2 } else { 4 };
assert_eq!(
cpu.step_timed(&mut memory).unwrap().code_cycles,
if thumb { 5 } else { 8 }
);
assert_eq!(cpu.pc(), 8);
assert_eq!(
cpu.step_timed(&mut memory).unwrap().code_cycles,
if thumb { 9 } else { 15 }
);
assert_eq!((cpu.pc(), cpu.instruction_set()), (return_pc, state));
assert_eq!(
cpu.step_timed(&mut memory).unwrap().code_cycles,
if thumb { 3 } else { 6 }
);
assert_eq!(memory.cycles(), 0);
}
}
#[test]
fn timed_and_cpu_only_execution_commit_the_same_queue_without_device_clocks() {
for thumb in [false, true] {
let load = if thumb { 0x6808 } else { 0xe591_0000 };
let nop = if thumb { 0x46c0 } else { NOP };
let (mut timed, mut a) = prepared(thumb, &[load, nop, load, nop], &[(1, 0x0200_1000)]);
let (mut plain, mut b) = prepared(thumb, &[load, nop, load, nop], &[(1, 0x0200_1000)]);
enable(&mut a);
enable(&mut b);
for _ in 0..4 {
let timing = timed.step_timed(&mut a).unwrap();
plain.step(&mut b).unwrap();
assert_eq!(timed, plain);
assert_eq!(a.gamepak_prefetch, b.gamepak_prefetch);
assert_eq!(timing, b.last_cpu_timing.unwrap().total);
assert_eq!((a.cycles(), b.cycles()), (0, 0));
}
}
}
#[test]
fn failures_discard_source_and_partial_data_progress() {
for (bad, address) in [(0xe591_0000, 0x0e00_0000), (0xe8b1_0005, IME)] {
let (mut cpu, mut memory) = prepared(false, &[NOP, bad], &[(1, address)]);
enable(&mut memory);
cpu.step_timed(&mut memory).unwrap();
let queue = memory.gamepak_prefetch;
let previous = memory.last_cpu_timing.unwrap().events();
let before = cpu.clone();
for _ in 0..2 {
assert!(cpu.step_timed(&mut memory).is_err());
assert_eq!(cpu, before);
assert_eq!(memory.gamepak_prefetch, queue);
assert_eq!(memory.last_cpu_timing.unwrap().events(), previous);
assert!(memory.cpu_timing.get().is_none());
}
}
}
#[test]
fn background_work_never_reads_missing_rom_bytes_or_fails_a_branch_early() {
let mut memory = Memory::new(0xea00_003e_u32.to_le_bytes().to_vec()).unwrap();
let mut cpu = Cpu::new(ROM_START);
enable(&mut memory);
assert_eq!(cpu.step_timed(&mut memory).unwrap().code_cycles, 20);
memory.advance_halt_cycles(1000); // Background addresses are beyond the file.
let queue = memory.gamepak_prefetch;
assert!(cpu.step_timed(&mut memory).is_err());
assert_eq!(memory.gamepak_prefetch, queue);
}
#[test]
fn host_access_and_clock_only_updates_do_not_advance_or_consume_the_queue() {
let (mut cpu, mut memory) = prepared(false, &[NOP, NOP], &[]);
enable(&mut memory);
cpu.step_timed(&mut memory).unwrap();
let queue = memory.gamepak_prefetch;
memory.read32(ROM_START).unwrap();
memory.write32(RAM, NOP).unwrap();
memory.advance_cycles(1000);
assert_eq!(memory.gamepak_prefetch, queue);
assert_eq!(cpu.step_timed(&mut memory).unwrap().code_cycles, 6);
}
#[test]
fn debugger_invalidation_discards_queued_timing_on_success() {
let (mut cpu, mut memory) = prepared(false, &[0xe591_0000, NOP], &[(1, 0x0200_1000)]);
enable(&mut memory);
cpu.step_timed(&mut memory).unwrap();
cpu.invalidate_pipeline();
assert_eq!(cpu.step_timed(&mut memory).unwrap().code_cycles, 6); // Cold nominal S, not a hit.
}
#[test]
fn cpu_waitcnt_write_resets_after_the_old_source_and_data_costs() {
for new in [0, 0x4018, 0x4001, 0x4800] {
let (mut cpu, mut memory) = prepared(false, &[0xe581_0000, NOP], &[(0, new), (1, WAITCNT)]);
enable(&mut memory);
let old = cpu.step_timed(&mut memory).unwrap();
assert_eq!((old.code_cycles, old.data_cycles), (6, 1));
assert_eq!(memory.waitcnt(), new as u16);
let expected = match new {
0 => 8, // Disabled: CPU N request.
0x4018 => 5, // Changed waits reset queue: new N+S.
_ => 5, // SRAM/PHI keep queue: 6 minus the store's free cycle.
};
assert_eq!(cpu.step_timed(&mut memory).unwrap().code_cycles, expected);
}
}
#[test]
fn host_byte_and_dma_waitcnt_writes_apply_queue_configuration() {
let (mut cpu, mut memory) = prepared(false, &[NOP, NOP, NOP], &[]);
enable(&mut memory);
cpu.step_timed(&mut memory).unwrap();
memory.write8(WAITCNT, 0x18).unwrap();
assert_eq!(cpu.step_timed(&mut memory).unwrap().code_cycles, 4);
memory.write32(RAM, 0).unwrap();
dma(&mut memory, RAM, WAITCNT);
memory.step_dma().unwrap().unwrap();
assert_eq!(memory.waitcnt(), 0);
assert_eq!(cpu.step_timed(&mut memory).unwrap().code_cycles, 8);
}
#[test]
fn ram_dma_advances_the_queue_and_rom_dma_cancels_it_before_cpu_resume() {
for (source, destination, expected_dma, expected_cpu) in [
(RAM, RAM + 4, 4, 2), // Startup 2 + RAM 1+1 gives four free cycles.
(0x0200_1000, RAM, 9, 1), // EWRAM gives a complete word.
(ROM_START, RAM, 12, 8), // Startup leaves remaining=1: ROM data stalls one cycle.
] {
let (mut cpu, mut memory) = prepared(false, &[NOP, NOP], &[]);
enable(&mut memory);
cpu.step_timed(&mut memory).unwrap();
dma(&mut memory, source, destination);
let (_, timing) = memory.step_dma().unwrap().unwrap();
assert_eq!(timing.total(), expected_dma);
assert_eq!(memory.cycles(), u64::from(expected_dma));
assert_eq!(
cpu.step_timed(&mut memory).unwrap().code_cycles,
expected_cpu
);
assert!(!memory.cpu_resume_nonsequential);
}
}
#[test]
fn failed_dma_preserves_queue_and_clocks() {
for (source, destination) in [(0x0e00_0000, RAM), (RAM, ROM_START)] {
let (mut cpu, mut memory) = prepared(false, &[NOP], &[]);
enable(&mut memory);
cpu.step_timed(&mut memory).unwrap();
let queue = memory.gamepak_prefetch;
dma(&mut memory, source, destination);
assert!(memory.step_dma().is_err());
assert_eq!(memory.gamepak_prefetch, queue);
assert_eq!(memory.cycles(), 0);
assert!(!memory.cpu_resume_nonsequential);
}
}
#[test]
fn machine_advances_prefetch_once_and_halt_progresses_while_stop_freezes() {
let (cpu, mut memory) = prepared(false, &[0xe591_0000, NOP], &[(1, RAM)]);
enable(&mut memory);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
machine.step().unwrap();
assert_eq!(machine.last_timing().code_cycles, 4); // Bulk device advance must not fill queue again.
assert_eq!(machine.cycles(), 12);
for stop in [false, true] {
let (mut cpu, mut memory) = prepared(false, &[NOP], &[]);
enable(&mut memory);
cpu.step_timed(&mut memory).unwrap();
memory.write8(HALTCNT, if stop { 0x80 } else { 0 }).unwrap();
let queue = memory.gamepak_prefetch;
let mut machine = Machine::new(cpu, memory);
assert_eq!(
machine.step().unwrap(),
if stop {
StepKind::StopIdle
} else {
StepKind::HaltIdle
}
);
if stop {
assert_eq!(machine.memory().gamepak_prefetch, queue);
assert_eq!(machine.cycles(), 0);
} else {
assert_ne!(machine.memory().gamepak_prefetch, queue);
assert!(machine.cycles() > 0);
}
}
}
#[test]
fn irq_source_uses_queued_code_and_bios_vector_fetches_advance_the_rom_stream() {
let (mut cpu, mut memory) = prepared(false, &[0xe591_0000, NOP], &[(1, RAM)]);
enable(&mut memory);
cpu.step_timed(&mut memory).unwrap(); // Next word has two cycles of progress.
memory.write16(IE, 8).unwrap();
memory.write16(IME, 1).unwrap();
memory.write32(TIMER_BASE, 0x00c0_ffff).unwrap();
memory.advance_cycles(1);
let mut machine = Machine::new(cpu, memory);
assert_eq!(machine.step().unwrap(), StepKind::IrqEntry);
assert_eq!(machine.last_timing().code_cycles, 6); // Source 4 plus vector 1+1.
let mut queue = machine.memory().gamepak_prefetch;
let events = machine.memory().last_cpu_timing.unwrap().events();
let TimingEvent::Code { address, .. } = events[0] else {
panic!("missing source")
};
assert_eq!(
queue.code(address + 4, AccessWidth::Word, AccessKind::Sequential),
4
);
}

View file

@ -13,7 +13,11 @@ const THUMB: u32 = ROM_START + 0x180;
const DATA: u32 = 0x0300_1000; const DATA: u32 = 0x0300_1000;
const ARM_NOP: u32 = 0xe1a0_0000; const ARM_NOP: u32 = 0xe1a0_0000;
fn prepared(thumb: bool, instructions: &[u32], operands: &[(usize, u32)]) -> (Cpu, Memory) { pub(super) fn prepared(
thumb: bool,
instructions: &[u32],
operands: &[(usize, u32)],
) -> (Cpu, Memory) {
let mut operands = operands.to_vec(); let mut operands = operands.to_vec();
if thumb { if thumb {
operands.push((12, THUMB | 1)); operands.push((12, THUMB | 1));
@ -210,7 +214,7 @@ fn swaps_and_register_shifts_place_internal_cycles_after_their_bus_work() {
#[test] #[test]
fn waitcnt_store_records_old_source_settings_and_next_step_records_new_settings() { fn waitcnt_store_records_old_source_settings_and_next_step_records_new_settings() {
for waitcnt in [0x18, 0x4018] { for waitcnt in [0x18, 0x4018] {
// Prefetch enable is still stored only. // Enabling prefetch does not arm a stream before the next ROM code fetch.
let (mut cpu, mut memory) = prepared( let (mut cpu, mut memory) = prepared(
false, false,
&[0xe581_0000, ARM_NOP], &[0xe581_0000, ARM_NOP],

View file

@ -1,5 +1,8 @@
//! Nominal GBA bus costs. These calculations do not perform memory accesses. //! Nominal GBA bus costs. These calculations do not perform memory accesses.
//! Game Pak prefetch, display-bus contention, and configurable EWRAM timing are not modeled. //! Game Pak prefetch has a separate nominal queue; display contention and configurable EWRAM timing remain unmodeled.
mod prefetch;
pub(crate) use prefetch::Prefetch;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessWidth { pub enum AccessWidth {
@ -95,6 +98,8 @@ pub fn bus_cycles(waitcnt: u16, address: u32, width: AccessWidth, kind: AccessKi
#[derive(Debug, Default, Clone, Copy)] #[derive(Debug, Default, Clone, Copy)]
pub(crate) struct CpuTiming { pub(crate) struct CpuTiming {
pub total: StepTiming, pub total: StepTiming,
pub prefetch: Prefetch,
#[cfg(test)]
code_waitcnt: u16, code_waitcnt: u16,
next_data: Option<u32>, next_data: Option<u32>,
// The largest supported step is LDM with PC: source + 16 data + I + 2 targets. // The largest supported step is LDM with PC: source + 16 data + I + 2 targets.
@ -106,15 +111,18 @@ pub(crate) struct CpuTiming {
} }
impl CpuTiming { impl CpuTiming {
pub fn new(code_waitcnt: u16) -> Self { pub fn new(code_waitcnt: u16, mut prefetch: Prefetch) -> Self {
prefetch.configure(code_waitcnt);
Self { Self {
prefetch,
#[cfg(test)]
code_waitcnt, code_waitcnt,
..Self::default() ..Self::default()
} }
} }
pub fn code(&mut self, address: u32, width: AccessWidth, kind: AccessKind) { pub fn code(&mut self, address: u32, width: AccessWidth, kind: AccessKind) {
let cycles = bus_cycles(self.code_waitcnt, address, width, kind); let cycles = self.prefetch.code(address, width, kind);
self.total.code_cycles += cycles; self.total.code_cycles += cycles;
#[cfg(test)] #[cfg(test)]
self.record(TimingEvent::Code { self.record(TimingEvent::Code {
@ -132,7 +140,8 @@ impl CpuTiming {
} else { } else {
AccessKind::NonSequential AccessKind::NonSequential
}; };
let cycles = bus_cycles(waitcnt, address, width, kind); self.prefetch.configure(waitcnt);
let cycles = self.prefetch.data(address, width, kind);
self.total.data_cycles += cycles; self.total.data_cycles += cycles;
self.next_data = Some(address.wrapping_add(width.bytes())); self.next_data = Some(address.wrapping_add(width.bytes()));
#[cfg(test)] #[cfg(test)]
@ -147,6 +156,7 @@ impl CpuTiming {
pub fn internal(&mut self, cycles: u32) { pub fn internal(&mut self, cycles: u32) {
self.total.internal_cycles += cycles; self.total.internal_cycles += cycles;
self.prefetch.advance(cycles);
#[cfg(test)] #[cfg(test)]
if cycles != 0 { if cycles != 0 {
self.record(TimingEvent::Internal { cycles }); self.record(TimingEvent::Internal { cycles });

View file

@ -0,0 +1,165 @@
//! Nominal Game Pak opcode prefetch timing. No ROM bytes or CPU instructions live here.
//! Full-buffer pause/restart follows the independently reviewed ares/jgenesis model.
use super::{bus_cycles, AccessKind, AccessWidth};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Prefetch {
waitcnt: u16,
stream: Option<Stream>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Stream {
head: u32,
ready: u8,
remaining: u32,
running: bool,
}
impl Prefetch {
pub fn configure(&mut self, waitcnt: u16) {
// Changes to ROM waits or enable invalidate timing metadata. PHI/SRAM do not.
// Exact hardware behavior of disabling a partially filled queue remains unverified.
if (self.waitcnt ^ waitcnt) & 0x47fc != 0 {
self.clear();
}
self.waitcnt = waitcnt;
}
pub fn clear(&mut self) {
self.stream = None;
}
fn enabled(&self) -> bool {
self.waitcnt & 0x4000 != 0
}
fn can_produce(address: u32) -> bool {
(0x08..=0x0d).contains(&(address >> 24)) && address & 0x1fffe != 0
}
fn start(&mut self, address: u32) {
self.stream = Some(Stream {
head: address,
ready: 0,
remaining: bus_cycles(
self.waitcnt,
address,
AccessWidth::Halfword,
AccessKind::Sequential,
),
running: Self::can_produce(address),
});
}
/// Advance only while the cartridge bus is free. Work is bounded by queue capacity.
pub fn advance(&mut self, mut cycles: u32) {
if !self.enabled() {
return;
}
let Some(stream) = &mut self.stream else {
return;
};
while cycles != 0 && stream.running {
if stream.ready == 8 {
stream.running = false;
stream.remaining = 0;
break;
}
let elapsed = cycles.min(stream.remaining);
cycles -= elapsed;
stream.remaining -= elapsed;
if stream.remaining == 0 {
stream.ready += 1;
let tail = stream.head.wrapping_add(u32::from(stream.ready) * 2);
stream.running = Self::can_produce(tail);
stream.remaining = bus_cycles(
self.waitcnt,
tail,
AccessWidth::Halfword,
AccessKind::Sequential,
);
}
}
}
/// Cancelling the last cycle of an active halfword adds one nominal stall cycle.
fn cancel(&mut self) -> u32 {
let stall = u32::from(
self.stream
.is_some_and(|s| s.running && s.ready < 8 && s.remaining == 1),
);
self.clear();
stall
}
pub fn code(&mut self, address: u32, width: AccessWidth, kind: AccessKind) -> u32 {
let raw = bus_cycles(self.waitcnt, address, width, kind);
if !(0x08..=0x0d).contains(&(address >> 24)) {
self.advance(raw);
return raw;
}
if !self.enabled() {
return raw;
}
debug_assert!(matches!(width, AccessWidth::Halfword | AccessWidth::Word));
let matches = self
.stream
.is_some_and(|s| s.head == address && (s.ready != 0 || s.running))
&& Self::can_produce(address);
if !matches {
let drained = self
.stream
.is_some_and(|s| s.head == address && s.ready == 0 && !s.running);
let stall = self.cancel();
let cycles = if drained {
bus_cycles(self.waitcnt, address, width, AccessKind::NonSequential)
} else {
raw
};
self.start(address.wrapping_add(width.bytes()));
return cycles + stall;
}
// One CPU-side cycle can deliver either a halfword or a fully queued ARM word.
let mut cycles = 1;
self.advance(1);
for _ in 0..width.bytes() / 2 {
let mut stream = self.stream.unwrap();
if stream.ready == 0 {
if !stream.running {
// Full-buffer stop can also be reached between an ARM word's halves.
stream.running = true;
stream.remaining = bus_cycles(
self.waitcnt,
stream.head,
AccessWidth::Halfword,
AccessKind::NonSequential,
);
self.stream = Some(stream);
}
let wait = stream.remaining;
self.advance(wait);
cycles += wait;
}
let stream = self.stream.as_mut().unwrap();
debug_assert!(stream.ready > 0);
stream.ready -= 1;
stream.head = stream.head.wrapping_add(2);
}
cycles
}
pub fn data(&mut self, address: u32, width: AccessWidth, kind: AccessKind) -> u32 {
let raw = bus_cycles(self.waitcnt, address, width, kind);
if (0x08..=0x0f).contains(&(address >> 24)) {
raw + self.cancel()
} else {
self.advance(raw);
raw
}
}
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,305 @@
//! Independent queue expectations. These tests do not call bus_cycles for expected costs.
use super::*;
const PC: u32 = 0x0800_1000;
const ENABLE: u16 = 0x4000;
fn queue(waitcnt: u16) -> Prefetch {
let mut queue = Prefetch::default();
queue.configure(waitcnt);
queue
}
#[test]
fn ready_and_in_progress_fetches_follow_all_rom_wait_settings_and_widths() {
for window in 0..3 {
for first in 0..4 {
for second in 0..2 {
let waitcnt =
ENABLE | (first << [2, 5, 8][window]) | (second << [4, 7, 10][window]);
let n = [5, 4, 3, 9][first as usize];
let s = if second == 1 { 2 } else { [3, 5, 9][window] };
for width in [AccessWidth::Halfword, AccessWidth::Word] {
let halves = width.bytes() / 2;
let pc = PC + window as u32 * 0x0200_0000;
for idle in 0..=2 * s + 2 {
for kind in [AccessKind::Sequential, AccessKind::NonSequential] {
let mut q = queue(waitcnt);
assert_eq!(
q.code(pc, width, AccessKind::NonSequential),
n + (halves - 1) * s
);
q.advance(idle);
// A live cartridge burst ignores the CPU's N request, including after I cycles.
assert_eq!(
q.code(pc + width.bytes(), width, kind),
(halves * s).saturating_sub(idle).max(1),
"window={window} waitcnt={waitcnt:#x} width={width:?} idle={idle}"
);
}
}
}
}
}
}
}
#[test]
fn sequential_fetches_without_spare_bus_time_keep_the_sequential_rom_cost() {
for width in [AccessWidth::Halfword, AccessWidth::Word] {
let mut q = queue(ENABLE);
q.code(PC, width, AccessKind::Sequential);
for index in 1..100 {
assert_eq!(
q.code(PC + index * width.bytes(), width, AccessKind::Sequential),
if width == AccessWidth::Word { 6 } else { 3 }
);
}
}
}
#[test]
fn full_queue_pauses_until_drained_then_restarts_nonsequentially() {
for width in [AccessWidth::Halfword, AccessWidth::Word] {
let mut q = queue(ENABLE);
q.code(PC, width, AccessKind::Sequential);
q.advance(25); // Eight halfwords at S=3, then one cycle observing full capacity.
assert_eq!(q.stream.unwrap().ready, 8);
assert!(!q.stream.unwrap().running);
let mut huge = q;
huge.advance(u32::MAX);
assert_eq!(q, huge);
let count = 16 / width.bytes();
for index in 1..=count {
assert_eq!(
q.code(PC + index * width.bytes(), width, AccessKind::Sequential),
1
);
}
assert_eq!(
q.code(
PC + (count + 1) * width.bytes(),
width,
AccessKind::Sequential
),
if width == AccessWidth::Word { 8 } else { 5 }
);
assert_eq!(
q.code(
PC + (count + 2) * width.bytes(),
width,
AccessKind::Sequential
),
if width == AccessWidth::Word { 6 } else { 3 }
);
}
}
#[test]
fn arm_fetch_can_combine_a_ready_low_half_with_an_in_progress_high_half() {
for (idle, cost) in [(0, 6), (1, 5), (2, 4), (3, 3), (4, 2), (5, 1), (6, 1)] {
let mut q = queue(ENABLE);
q.code(PC, AccessWidth::Word, AccessKind::NonSequential);
q.advance(idle);
assert_eq!(
q.code(PC + 4, AccessWidth::Word, AccessKind::NonSequential),
cost
);
}
// Halfword ownership also permits a state change at the exact next queued address.
let mut q = queue(ENABLE);
q.code(PC + 2, AccessWidth::Halfword, AccessKind::NonSequential);
q.advance(6);
assert_eq!(
q.code(PC + 4, AccessWidth::Word, AccessKind::NonSequential),
1
);
assert_eq!(
q.code(PC + 8, AccessWidth::Halfword, AccessKind::Sequential),
2
);
}
#[test]
fn arm_word_restarts_between_halves_after_draining_an_odd_full_queue() {
let mut q = queue(ENABLE);
q.code(PC, AccessWidth::Halfword, AccessKind::Sequential);
q.advance(25);
assert_eq!(
q.code(PC + 2, AccessWidth::Halfword, AccessKind::Sequential),
1
);
for offset in [4, 8, 12] {
assert_eq!(
q.code(PC + offset, AccessWidth::Word, AccessKind::Sequential),
1
);
}
// The low half is the last ready entry. The missing high half restarts with N=5.
assert_eq!(
q.code(PC + 16, AccessWidth::Word, AccessKind::Sequential),
6
);
assert_eq!(
q.code(PC + 20, AccessWidth::Word, AccessKind::Sequential),
6
);
}
#[test]
fn cancellation_stalls_only_at_the_last_cycle_of_an_active_halfword() {
for idle in 0..=26 {
let stall = u32::from(idle < 24 && idle % 3 == 2);
let mut q = queue(ENABLE);
q.code(PC, AccessWidth::Halfword, AccessKind::Sequential);
q.advance(idle);
let mut branch = q;
assert_eq!(
q.data(PC + 0x100, AccessWidth::Word, AccessKind::NonSequential),
8 + stall
);
assert!(q.stream.is_none());
assert_eq!(
branch.code(PC + 0x100, AccessWidth::Word, AccessKind::NonSequential),
8 + stall
);
assert_eq!(branch.stream.unwrap().head, PC + 0x104);
}
}
#[test]
fn only_the_queue_head_can_hit_and_rom_data_never_consumes_opcode_entries() {
let mut q = queue(ENABLE);
q.code(PC, AccessWidth::Halfword, AccessKind::Sequential);
q.advance(12);
assert_eq!(
q.code(PC + 6, AccessWidth::Halfword, AccessKind::NonSequential),
5
);
q.advance(12);
let head = q.stream.unwrap().head;
assert_eq!(
q.data(head, AccessWidth::Halfword, AccessKind::NonSequential),
5
);
assert!(q.stream.is_none());
}
#[test]
fn other_memory_and_internal_work_advance_the_queue_without_arming_it() {
let mut q = queue(ENABLE);
assert_eq!(
q.data(0x0200_0000, AccessWidth::Word, AccessKind::NonSequential),
6
);
q.advance(10);
assert!(q.stream.is_none());
q.code(PC, AccessWidth::Word, AccessKind::Sequential);
assert_eq!(
q.data(0x0200_0000, AccessWidth::Word, AccessKind::NonSequential),
6
);
assert_eq!(
q.code(PC + 4, AccessWidth::Word, AccessKind::NonSequential),
1
);
assert_eq!(
q.code(0x0300_0000, AccessWidth::Word, AccessKind::NonSequential),
1
);
q.advance(1);
assert_eq!(
q.code(PC + 8, AccessWidth::Halfword, AccessKind::NonSequential),
1
);
}
#[test]
fn page_boundaries_pause_background_work_and_demand_uses_the_new_window() {
for (boundary, word_n, word_s) in [
(0x0802_0000, 8, 6),
(0x0a00_0000, 10, 10),
(0x0c00_0000, 14, 18),
] {
let mut q = queue(ENABLE);
q.code(boundary - 8, AccessWidth::Word, AccessKind::Sequential);
q.advance(u32::MAX);
assert_eq!(q.stream.unwrap().ready, 2);
assert!(!q.stream.unwrap().running);
assert_eq!(
q.code(boundary - 4, AccessWidth::Word, AccessKind::Sequential),
1
);
assert_eq!(
q.code(boundary, AccessWidth::Word, AccessKind::Sequential),
word_n
);
assert_eq!(
q.code(boundary + 4, AccessWidth::Word, AccessKind::Sequential),
word_s
);
}
let mut q = queue(ENABLE);
q.code(0x0dff_fffc, AccessWidth::Word, AccessKind::Sequential);
q.advance(u32::MAX);
assert_eq!(q.stream.unwrap().ready, 0); // Never prefetch into cartridge save space.
}
#[test]
fn relevant_waitcnt_changes_clear_the_queue_but_phi_and_sram_fields_do_not() {
for changed in [0, ENABLE | 4, ENABLE | 0x10, ENABLE | 0x80, ENABLE | 0x400] {
let mut q = queue(ENABLE);
q.code(PC, AccessWidth::Word, AccessKind::Sequential);
q.advance(10);
q.configure(changed);
assert!(q.stream.is_none());
}
let mut q = queue(ENABLE);
q.code(PC, AccessWidth::Word, AccessKind::Sequential);
q.advance(10);
let stream = q.stream;
q.configure(ENABLE | 0x1803);
assert_eq!(q.stream, stream);
assert_eq!(q.code(PC + 4, AccessWidth::Word, AccessKind::Sequential), 1);
}
#[test]
fn disabled_prefetch_retains_raw_costs_and_never_starts_background_work() {
let mut q = queue(0);
for _ in 0..3 {
assert_eq!(q.code(PC, AccessWidth::Word, AccessKind::Sequential), 6);
assert_eq!(
q.code(PC + 4, AccessWidth::Word, AccessKind::NonSequential),
8
);
q.advance(u32::MAX);
assert!(q.stream.is_none());
}
}
#[test]
fn batched_progress_matches_single_clock_progress_for_seeded_event_sequences() {
let mut fast = queue(ENABLE);
let mut slow = fast;
let mut seed = 0x1234_5678_u32;
let mut address = PC;
for _ in 0..10_000 {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
let idle = seed >> 24;
fast.advance(idle);
for _ in 0..idle {
slow.advance(1);
}
assert_eq!(fast, slow);
let width = if address & 3 == 0 && seed & 1 == 0 {
AccessWidth::Word
} else {
AccessWidth::Halfword
};
assert_eq!(
fast.code(address, width, AccessKind::Sequential),
slow.code(address, width, AccessKind::Sequential)
);
address += width.bytes();
}
}

View file

@ -52,6 +52,9 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/src/dma.rs` | DMA registers, internal pointers, retained channel data, trigger state, priority, and completion IRQs | | `crates/gba-core/src/dma.rs` | DMA registers, internal pointers, retained channel data, trigger state, priority, and completion IRQs |
| `crates/gba-core/src/machine.rs` | Timed CPU/DMA/device stepping and IRQ delivery | | `crates/gba-core/src/machine.rs` | Timed CPU/DMA/device stepping and IRQ delivery |
| `crates/gba-core/src/timing.rs` | Bus widths, wait-state costs, ordered CPU timing transactions, and timing breakdowns | | `crates/gba-core/src/timing.rs` | Bus widths, wait-state costs, ordered CPU timing transactions, and timing breakdowns |
| `crates/gba-core/src/timing/prefetch.rs` | Nominal eight-halfword opcode queue, partial transfers, capacity stops, and cancellation |
| `crates/gba-core/src/timing/prefetch/tests.rs` | Wait-state matrices, partial words, cancellation phases, boundaries, configuration, and progress consistency |
| `crates/gba-core/src/memory/prefetch_tests.rs` | CPU/DMA/idle queue ownership, WAITCNT writes, branch/IRQ timing, API isolation, and rollback |
| `crates/gba-core/src/cpu/pipeline.rs` | CPU-owned ARM/Thumb instructions, PC/state tags, next fetch kind, deferred diagnostics, refills, and invalidation | | `crates/gba-core/src/cpu/pipeline.rs` | CPU-owned ARM/Thumb instructions, PC/state tags, next fetch kind, deferred diagnostics, refills, and invalidation |
| `crates/gba-core/src/cpu/arm_pipeline_tests.rs` | ARM self-modifying code, branches, target pairs, DMA isolation, exceptions, errors, and timing equivalence | | `crates/gba-core/src/cpu/arm_pipeline_tests.rs` | ARM self-modifying code, branches, target pairs, DMA isolation, exceptions, errors, and timing equivalence |
| `crates/gba-core/src/cpu/thumb_pipeline_tests.rs` | Thumb retained halfwords, BL prefix/suffix, state-tagged refills, DMA isolation, rollback, and timing equivalence | | `crates/gba-core/src/cpu/thumb_pipeline_tests.rs` | Thumb retained halfwords, BL prefix/suffix, state-tagged refills, DMA isolation, rollback, and timing equivalence |

View file

@ -210,7 +210,7 @@ Other commands:
direnv exec . cargo run --locked -- --cpu-demo direnv exec . cargo run --locked -- --cpu-demo
# Run a timer-generated interrupt through an original test handler. # Run a timer-generated interrupt through an original test handler.
# Uses nominal instruction/bus costs; prefetch and sub-instruction timing are not modeled. # Uses nominal instruction/bus costs and opcode prefetch; sub-instruction device scheduling is not modeled.
direnv exec . cargo run --locked -- --timer-demo direnv exec . cargo run --locked -- --timer-demo
# Verify 60 CPU-driven frames with scripted movement and color changes. # Verify 60 CPU-driven frames with scripted movement and color changes.

View file

@ -360,13 +360,39 @@ Timed CPU execution uses an ordered `CpuTiming` transaction: source fetch, actua
A failed instruction discards the entire transaction. Target samples do not add duplicate fetch or data costs. A failed instruction discards the entire transaction. Target samples do not add duplicate fetch or data costs.
IRQ entry records its source fetch and vector pair in the same path. IRQ entry records its source fetch and vector pair in the same path.
Device clocks still advance once after the successful step; this is not per-cycle scheduling. Device clocks still advance once after the successful step; this is not per-cycle scheduling.
See [Game Pak prefetch research](../research/gamepak-prefetch.md) for this prerequisite and unresolved queue behavior. The transaction also stages the nominal Game Pak prefetch queue described below.
### Game Pak opcode prefetch
WAITCNT bit 14 enables an eight-halfword timing queue, separate from retained CPU instructions and bus-history values.
ROM opcode misses start a stream. Internal work and non-cartridge accesses advance an active stream.
Matching queued ARM words or Thumb halfwords cost one cycle; partial words wait for the remaining halfword work.
The CPU-side delivery cycle also advances active background work. A CPU N request can still hit the queue.
ROM data accesses and nonmatching ROM code requests cancel the stream.
Cancellation adds one cycle when an active halfword has one cycle remaining.
Full-buffer production stops until the entries drain; the next empty demand restarts with N timing.
Background production pauses before 128 KiB boundaries, where demand uses forced-N timing.
The queue stores no instruction bytes and performs no background memory reads.
Missing lookahead and branch targets therefore keep their existing deferred diagnostic behavior.
Successful CPU transactions commit queue progress; failures discard it.
`Cpu::step` maintains the same queue as `Cpu::step_timed` when prefetch is enabled, without advancing device clocks.
Cold or invalidated CPU pipelines clear staged queue metadata and retain nominal S startup timing.
Changes to ROM wait fields or prefetch enable clear queue metadata after the WAITCNT write.
PHI/SRAM-only changes preserve it. Exact hardware live-reconfiguration behavior remains unverified.
RAM DMA and HALT supply idle cartridge time; cartridge DMA cancels the stream. STOP freezes queue progress.
Host reads and `Memory::advance_cycles` do not advance the queue; machine steps account for progress separately.
These are source-backed nominal rules, not a hardware conformance claim.
See [queue evidence, regression coverage, and limits](../research/gamepak-prefetch.md).
### Nominal CPU resume after DMA ### Nominal CPU resume after DMA
A successful DMA unit marks the next CPU instruction's nominal code access non-sequential. A successful DMA unit marks the next CPU instruction's nominal code access non-sequential.
This applies even when DMA only accesses RAM. Consecutive units retain one pending resume, not multiple penalties. This applies even when DMA only accesses RAM. Consecutive units retain one pending resume, not multiple penalties.
The source fetch uses its actual lookahead address, incoming width, and current WAITCNT with N instead of S. The source request uses its actual lookahead address, incoming width, and current WAITCNT with N instead of S.
A matching enabled prefetch stream can satisfy that request without paying the raw N cost.
This includes branches and accepted IRQ entry; target pairs still use N then S. This includes branches and accepted IRQ entry; target pairs still use N then S.
ARM word accesses retain an S cost for their second halfword. ARM word accesses retain an S cost for their second halfword.
CPU history and ROM boundaries that already require N do not receive an additional access. CPU history and ROM boundaries that already require N do not receive an additional access.
@ -390,7 +416,7 @@ Important timing limits:
Cold entry, debugger invalidation, and PC/state mismatches use nominal S, not hardware-validated startup timing. Cold entry, debugger invalidation, and PC/state mismatches use nominal S, not hardware-validated startup timing.
- Refill cost calculation does not read target bytes. ARM/Thumb instruction-buffer and local IWRAM bus-history samples add no extra cycles. - Refill cost calculation does not read target bytes. ARM/Thumb instruction-buffer and local IWRAM bus-history samples add no extra cycles.
- Target-pair sampling cannot fail the branch early. An invalid branch target still fails on the following instruction fetch. - Target-pair sampling cannot fail the branch early. An invalid branch target still fails on the following instruction fetch.
- Game Pak prefetch is not implemented. WAITCNT bit 14 is stored but does not accelerate execution. - Game Pak prefetch is nominal. Full-buffer restart, page boundaries, cancellation, and live WAITCNT changes need independent hardware validation.
- PHI and SRAM wait fields are stored; PHI output and SRAM mapping are not implemented. - PHI and SRAM wait fields are stored; PHI output and SRAM mapping are not implemented.
- External work RAM timing is fixed. The undocumented memory-control register is not implemented. - External work RAM timing is fixed. The undocumented memory-control register is not implemented.
- Exact DMA startup/resumption delays, display-bus contention, shared timer prescaler phase, and timer startup delays remain unmodeled. - Exact DMA startup/resumption delays, display-bus contention, shared timer prescaler phase, and timer startup delays remain unmodeled.

View file

@ -48,7 +48,11 @@ Timing uses independent source and destination bus costs, including WAITCNT and
The first unit uses non-sequential accesses plus two internal cycles. Later units use sequential accesses. The first unit uses non-sequential accesses plus two internal cycles. Later units use sequential accesses.
Each step reports zero code cycles. Overlapping transfers read each unit after the preceding write; they are not bulk host copies. Each step reports zero code cycles. Overlapping transfers read each unit after the preceding write; they are not bulk host copies.
A successful unit breaks the next nominal CPU code-access sequence, even if DMA accessed only RAM. A successful unit breaks the next nominal CPU code-access sequence, even if DMA accessed only RAM.
The resumed non-refill instruction uses N code timing once; later instructions return to their normal summaries. The resumed instruction requests N once; enabled opcode prefetch can still satisfy a matching request.
RAM accesses and the nominal startup cycles advance an active prefetch stream; cartridge accesses cancel it.
Cancellation can add one data cycle at the final halfword phase. DMA never consumes queued opcodes.
Only successful units commit queue progress; WAITCNT destinations apply configuration changes after the transfer.
Exact startup/completion placement remains nominal, not independently verified prefetch arbitration.
See [CPU resume timing](cpu.md#nominal-cpu-resume-after-dma) for failure, idle, and refill rules. See [CPU resume timing](cpu.md#nominal-cpu-resume-after-dma) for failure, idle, and refill rules.
Startup scheduling delays, channel-resumption costs, a full CPU fetch pipeline, and display-bus contention remain unmodeled. Startup scheduling delays, channel-resumption costs, a full CPU fetch pipeline, and display-bus contention remain unmodeled.

View file

@ -150,6 +150,7 @@ Display edges include every HBlank entry and scanline start. Cascaded timers rec
These bounds prevent idle advances from skipping DMA triggers or wake-up events. These bounds prevent idle advances from skipping DMA triggers or wake-up events.
They also keep frame execution bounded when no interrupt can wake the CPU. They also keep frame execution bounded when no interrupt can wake the CPU.
Idle steps neither fetch instructions nor change CPU registers; code, data, and internal cycle counts remain zero. Idle steps neither fetch instructions nor change CPU registers; code, data, and internal cycle counts remain zero.
HALT idle cycles advance an already active Game Pak opcode prefetch stream, without starting one.
Exact hardware entry/exit delays and pipeline effects are not modeled. Exact hardware entry/exit delays and pipeline effects are not modeled.
CPU writes to POSTFLG and HALTCNT take effect only when the executing instruction address is within the 16 KiB BIOS region. CPU writes to POSTFLG and HALTCNT take effect only when the executing instruction address is within the 16 KiB BIOS region.
@ -171,6 +172,7 @@ CPU-only stepping can record HALT/STOP but does not enforce either pause. Use `M
Writing HALTCNT with bit 7 set requests STOP. Bits 0–6 are ignored. Writing HALTCNT with bit 7 set requests STOP. Bits 0–6 are ignored.
The instruction that writes STOP finishes and pays its full nominal cycle cost. The instruction that writes STOP finishes and pays its full nominal cycle cost.
Later machine steps perform no CPU fetch, DMA transfer, timer tick, or display update. Later machine steps perform no CPU fetch, DMA transfer, timer tick, or display update.
The nominal Game Pak prefetch queue also remains frozen.
`StepKind::StopIdle` reports zero timing; it does not represent elapsed host wall time. `StepKind::StopIdle` reports zero timing; it does not represent elapsed host wall time.
`Memory::stopped()` and `Machine::stopped()` report this state. The corresponding `halted()` methods return false in STOP. `Memory::stopped()` and `Machine::stopped()` report this state. The corresponding `halted()` methods return false in STOP.

View file

@ -206,8 +206,13 @@ Game Pak prefetch research:
- The pinned NanoBoyAdvance bus timing above, for queued/in-progress/miss cases and completion-edge cancellation stalls. - The pinned NanoBoyAdvance bus timing above, for queued/in-progress/miss cases and completion-edge cancellation stalls.
- [ares prefetch at 6f6786e0](https://github.com/ares-emulator/ares/blob/6f6786e04f0822a3475463df284f313ab8518d51/ares/gba/cpu/prefetch.cpp), compared with its pinned bus dispatcher above, for halfword ownership, full-buffer stopping, and page-boundary behavior. - [ares prefetch at 6f6786e0](https://github.com/ares-emulator/ares/blob/6f6786e04f0822a3475463df284f313ab8518d51/ares/gba/cpu/prefetch.cpp), compared with its pinned bus dispatcher above, for halfword ownership, full-buffer stopping, and page-boundary behavior.
See [prefetch evidence and timing prerequisites](research/gamepak-prefetch.md). - [Jgenesis prefetch at fab6e2cc](https://github.com/jsgroth/jgenesis/blob/fab6e2ccc60e492dd68b7f1e927b0829a6d80195/backend/gba-core/src/prefetch.rs) and its [bus integration](https://github.com/jsgroth/jgenesis/blob/fab6e2ccc60e492dd68b7f1e927b0829a6d80195/backend/gba-core/src/bus.rs), for independent halfword capacity, full-buffer pause/restart, CPU delivery, and cancellation rules.
The source differences remain validation questions. No Game Pak prefetch implementation or hardware conformance result is claimed yet. - [NanoBoyAdvance boundary test at cc3f4a28](https://codeberg.org/nba-emu/hw-test/src/commit/cc3f4a286cdef823980d9b353bd70befc9927d28/bus/128kb-boundary/source/main.c), reviewed for data LDM/DMA boundaries, not active opcode-queue conformance.
- [PrefetchAbuse](https://github.com/zaydlang/PrefetchAbuse), a search-excerpt-only cancellation-test lead. Its source and ROM were not used.
See [prefetch behavior and evidence](research/gamepak-prefetch.md).
The nominal queue is implemented. Source differences and live WAITCNT transitions remain validation questions.
No hardware conformance result is claimed.
Use [GBATEK](https://problemkaputt.de/gbatek.htm) and ARM7TDMI documentation for further hardware work. Use [GBATEK](https://problemkaputt.de/gbatek.htm) and ARM7TDMI documentation for further hardware work.
Only use game ROMs that you may lawfully use. Do not commit game ROMs, BIOS files, or game assets. Only use game ROMs that you may lawfully use. Do not commit game ROMs, BIOS files, or game assets.

View file

@ -56,7 +56,7 @@ No extra access is appended. A resumed ROM branch can therefore cost more than i
The later [sequencing extension](fetch-access-sequencing.md) applies each instruction's data/internal effects to the following source fetch. The later [sequencing extension](fetch-access-sequencing.md) applies each instruction's data/internal effects to the following source fetch.
CPU history, DMA resume, and ROM page boundaries can all require N without duplicating an access. CPU history, DMA resume, and ROM page boundaries can all require N without duplicating an access.
A completed transfer after DMA can establish a new N requirement for the following fetch. A completed transfer after DMA can establish a new N requirement for the following fetch.
Game Pak prefetch remains unimplemented; WAITCNT bit 14 does not alter this nominal timing path. This baseline preceded active prefetch. The later [nominal queue](gamepak-prefetch.md) can satisfy an N resume request from queued code.
This change does not select a DMA value for CPU open-bus reads. This change does not select a DMA value for CPU open-bus reads.
DMA channel data, persistent local IWRAM lanes, and protected BIOS history remain separate. DMA channel data, persistent local IWRAM lanes, and protected BIOS history remain separate.

View file

@ -123,8 +123,10 @@ The timer therefore runs two additional cycles before the handler stops it.
- Cold-start costs and state-changing test/compare quirks are not hardware-validated. - Cold-start costs and state-changing test/compare quirks are not hardware-validated.
- Data timing still uses an instruction-local access trace. This is not a complete bus-event scheduler. - Data timing still uses an instruction-local access trace. This is not a complete bus-event scheduler.
- Device clocks still update once per instruction, IRQ entry, or DMA unit. - Device clocks still update once per instruction, IRQ entry, or DMA unit.
- Game Pak prefetch, configurable EWRAM timing, display contention, and DMA during internal cycles remain unimplemented. - Configurable EWRAM timing, display contention, and DMA during internal cycles remain unimplemented.
- IRQ synchronization delays and complete BIOS/general-bus latch history remain incomplete. - IRQ synchronization delays and complete BIOS/general-bus latch history remain incomplete.
The later [prefetch prerequisite](gamepak-prefetch.md) records source, data, internal, and refill costs in order without changing totals. The later ordered timing transaction preserved these baseline totals.
The subsequent [nominal prefetch queue](gamepak-prefetch.md) uses those events and can accelerate code despite a CPU N request.
CPU access-kind rules remain separate from the queue's cartridge-bus timing.
The next timing work remains an active Game Pak queue, with verified startup and invalidation rules and independent tests. The next timing work remains an active Game Pak queue, with verified startup and invalidation rules and independent tests.

View file

@ -72,10 +72,11 @@ The later [refill timing extension](refill-fetch-timing.md) also charges source
- Thumb BL prefixes use their lookahead address; suffixes add the target pair. - Thumb BL prefixes use their lookahead address; suffixes add the target pair.
- Cold fills have no separate startup charge, even though their samples update supported bus history. - Cold fills have no separate startup charge, even though their samples update supported bus history.
- Devices advance once after the whole successful instruction. No per-access timer/IRQ update is introduced. - Devices advance once after the whole successful instruction. No per-access timer/IRQ update is introduced.
- Game Pak prefetch, display contention, configurable EWRAM timing, and sub-instruction DMA arbitration remain unimplemented. - Display contention, configurable EWRAM timing, and sub-instruction DMA arbitration remain unimplemented.
- The later [nominal prefetch queue](gamepak-prefetch.md) changes code costs when bit 14 is enabled.
This is a fetch-address correction within the existing timing model, not cycle-accurate emulation. This is a fetch-address correction within the existing timing model, not cycle-accurate emulation.
The next timing step is Game Pak prefetch with verified startup and invalidation rules. Game Pak prefetch followed this baseline; independent startup and invalidation timing validation remains necessary.
## Original regression coverage ## Original regression coverage

View file

@ -1,115 +1,139 @@
# Game Pak prefetch: evidence and timing prerequisites # Game Pak prefetch: nominal queue and evidence
## Current result ## Current result
Game Pak prefetch is **not enabled in the core yet**. WAITCNT bit 14 enables a nominal Game Pak opcode prefetch queue.
WAITCNT bit 14 remains stored without accelerating instruction fetches. The queue tracks eight halfwords, partial transfers, full-buffer stops, and cancellation stalls.
This change prepares ordered CPU timing transactions without changing cycle totals or instruction results. It follows ordered CPU timing events and the existing instruction-boundary DMA scheduler.
This is source-backed timing behavior, not hardware-verified accuracy.
The previous timing path collected data costs during execution, then calculated source and refill costs afterward. The queue stores addresses and timing metadata, not ROM bytes.
That produced correct existing totals but could not directly advance a prefetch queue in bus-access order. CPU instruction retention, BIOS protected-read history, and IWRAM local lanes remain separate.
A prefetch implementation needs to distinguish cartridge-bus occupation from time available for background fetching. Background progress cannot cause an early ROM-file lookup or change instruction values.
An aggregate instruction cost cannot identify when to fill or invalidate a queue.
## Sources reviewed ## Sources reviewed
- [GBATEK GamePak Prefetch](https://www.problemkaputt.de/gbatek-gba-gamepak-prefetch.htm) describes eight 16-bit entries, opcode-only service, and prefetch during internal or other-memory accesses. - [GBATEK GamePak Prefetch](https://www.problemkaputt.de/gbatek-gba-gamepak-prefetch.htm) describes eight 16-bit entries, opcode-only service, and idle-bus progress.
- [mGBA's cycle-counting article](https://mgba.io/2015/06/27/cycle-counting-prefetch/) explains independent cartridge-bus progress, partially completed fetches, and instruction-pair examples. - [mGBA's cycle-counting article](https://mgba.io/2015/06/27/cycle-counting-prefetch/) explains independent cartridge progress and partially completed fetches.
Its examples and linked timing suite provide leads for independent validation, not a local hardware-test pass. Its examples and linked timing suite provide validation leads, not a local hardware-test pass.
- [NanoBoyAdvance bus timing at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/bus/timing.cc) distinguishes queue hits, in-progress fetches, misses, and cancellation stalls. - [NanoBoyAdvance bus timing at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/bus/timing.cc) distinguishes hits, partial fetches, misses, and cancellation stalls.
- [ares prefetch at 6f6786e0](https://github.com/ares-emulator/ares/blob/6f6786e04f0822a3475463df284f313ab8518d51/ares/gba/cpu/prefetch.cpp) provides an independent halfword-queue implementation. - [ares prefetch at 6f6786e0](https://github.com/ares-emulator/ares/blob/6f6786e04f0822a3475463df284f313ab8518d51/ares/gba/cpu/prefetch.cpp) uses halfword entries and stops at capacity or page boundaries.
Its [bus dispatcher](https://github.com/ares-emulator/ares/blob/6f6786e04f0822a3475463df284f313ab8518d51/ares/gba/cpu/bus.cpp) separates opcode requests, data requests, and other-region progress. Its [bus dispatcher](https://github.com/ares-emulator/ares/blob/6f6786e04f0822a3475463df284f313ab8518d51/ares/gba/cpu/bus.cpp) separates opcode requests, data requests, and other-memory progress.
- [Jgenesis prefetch at fab6e2cc](https://github.com/jsgroth/jgenesis/blob/fab6e2ccc60e492dd68b7f1e927b0829a6d80195/backend/gba-core/src/prefetch.rs) independently supports halfword entries, full-buffer stopping, and last-cycle cancellation stalls.
Its [bus integration](https://github.com/jsgroth/jgenesis/blob/fab6e2ccc60e492dd68b7f1e927b0829a6d80195/backend/gba-core/src/bus.rs) separates CPU delivery cycles from background transfers and DMA ownership.
- [NanoBoyAdvance 128 KiB boundary test at cc3f4a28](https://codeberg.org/nba-emu/hw-test/src/commit/cc3f4a286cdef823980d9b353bd70befc9927d28/bus/128kb-boundary/source/main.c) tests data LDM and DMA boundaries.
Reading this test does not validate active opcode-queue crossings. It was not run here.
- [PrefetchAbuse](https://github.com/zaydlang/PrefetchAbuse) appeared in search results as a cartridge-access cancellation test lead.
Only its search excerpt was reviewed. No expectations or implementation code were taken from it.
No external implementation or ROM was added to the repository. No external implementation or ROM was added to the repository.
No physical-hardware measurement or external-emulator differential run was performed. No physical-hardware measurement or external-emulator differential run was performed.
## Agreement and unresolved details ## Implemented queue rules
The sources agree on the main model:
- Prefetch is controlled by WAITCNT bit 14.
- The buffer serves Game Pak opcodes, not arbitrary ROM data loads.
- Internal CPU work and accesses to other memory can give the cartridge bus time to fetch ahead.
- A matching ready entry avoids ordinary ROM waits.
- A request for an in-progress fetch waits for the remaining work rather than restarting the full access.
- Cartridge data traffic and nonmatching code requests interact with the active prefetch sequence.
However, the implementation details are not interchangeable:
| Area | Source observation | Required follow-up |
| --- | --- | --- |
| Entry granularity | NanoBoyAdvance groups ARM words; ares tracks halfwords | Verify partial ARM fetches and ARM/Thumb transitions |
| Full buffer | ares stops production and restarts after draining; NanoBoyAdvance uses an instruction-sized countdown/count model | Establish capacity, pause, drain, and restart timing independently |
| ROM page boundary | ares prevents background reads across the forced-N boundary; NanoBoyAdvance's queue step uses its retained duty | Verify 128 KiB boundaries without assuming a normal ready-entry hit |
| Cancellation | Both contain completion-edge stall handling | Verify the exact halfword phase and which code/data requests trigger a stall |
| DMA and idle | Prefetch shares cartridge time with other bus owners | Verify RAM-only DMA, cartridge DMA, HALT/STOP, and resume separately |
| WAITCNT changes | Queue state and current transfer timing depend on register changes | Define enable, disable, and changed-wait behavior before integration |
These differences do not establish that either implementation is wrong.
They show why copying one queue policy is not sufficient evidence for the entire subsystem.
The first implementation should state its supported cases and retain explicit limits for unresolved behavior.
## Ordered CPU timing transactions
`CpuTiming` replaces the data-only accumulator.
A timed CPU instruction now records these events in order:
1. Source instruction fetch, using the incoming access kind and instruction width.
2. Actual data reads/writes, using their aligned bus addresses and widths.
3. Internal CPU cycles from the incoming instruction classification.
4. Refill target N and target+width S accesses, using the resulting instruction state.
S means sequential access; N means non-sequential access. S means sequential access; N means non-sequential access.
An instruction without data, internal work, or a refill omits those events. Raw ROM costs include one clock plus the configured wait states for each halfword.
The two refill events occur in `Cpu::refill_pipeline`, alongside its existing mapped target samples. The public `bus_cycles` helper remains stateless and does not apply prefetch.
No separate refill-cost formula runs after execution.
IRQ entry records its discarded incoming-state source fetch before its ARM vector pair, without a data or internal event.
Code timing retains the entry WAITCNT snapshot. 1. A ROM code miss pays the requested raw cost, plus any cancellation stall.
Data timing retains the settings observed at each actual bus access, including pre-write settings for a WAITCNT store. It starts background fetching at the following halfword address.
The next instruction uses the new WAITCNT settings. 2. Internal cycles and non-cartridge memory accesses advance an already active stream.
The existing data N/S rule and persistent CPU source-fetch kind are unchanged. They cannot start a stream by themselves.
3. A code request must match the queue head. CPU N requests can still match queued code.
A queued Thumb halfword or complete ARM word costs one CPU-side cycle.
4. That CPU-side cycle also advances background work when production is active.
A partial ARM word waits separately for its missing halfwords.
5. Background production stops when an advance observes all eight entries occupied.
It remains stopped while the queue drains. The next empty demand restarts with N timing.
6. ROM data accesses cancel the stream instead of consuming opcode entries.
A nonmatching ROM code request also cancels it before starting a new stream.
7. Cancellation costs one additional cycle when an active halfword has one cycle remaining.
A paused or fully occupied queue does not add that stall.
8. Background production pauses before a 128 KiB ROM boundary.
Demand at that boundary uses raw forced-N timing and starts the next stream.
No background transfer crosses beyond the final ROM window.
The transaction returns the same `StepTiming` fields as before. The full-buffer policy follows ares and Jgenesis.
A diagnostic discards the entire transaction, including any data events recorded before a late failure. The page-boundary policy follows ares conservatively; the reviewed sources differ in this area.
Missing lookahead or target bytes retain their existing deferred diagnostic policy and nominal attempted-fetch cost. The cancellation cycle is included in the requesting code or data field of `StepTiming`.
Device clocks still advance once after a successful machine step. These rules have original regression tests, not a hardware conformance result.
This is ordered cost accounting, not a cycle-by-cycle bus scheduler.
Untimed execution, host inspection, cold-fill sampling, and DMA do not join a CPU timing transaction. ## Timing transactions and ownership
They retain their existing behavior and do not create extra code/data charges.
Untimed CPU execution still updates instruction retention and the next fetch kind, but does not compute a timing transaction. `Memory` owns the committed queue because CPU, DMA, and HALT share cartridge-bus time.
A future active prefetch model must define timing-state progression for this API explicitly. Each `CpuTiming` transaction stages a copy and applies events in order:
1. Source instruction fetch at ARM P+8 or Thumb P+4.
2. Actual data accesses, using aligned bus addresses and widths.
3. Internal cycles from the incoming instruction classification.
4. Refill target N and target+width S accesses, using the resulting instruction state.
IRQ entry records the discarded incoming-state source, then the ARM vector pair.
Non-ROM code fetches advance the existing ROM stream without consuming it.
A branch can therefore preserve the stream while executing a BIOS handler.
A later ROM fetch still needs an exact head match to use queued timing.
Success commits queue progress. A diagnostic discards all staged progress, including earlier successful data accesses.
Missing lookahead or target bytes retain deferred diagnostics and nominal attempted-fetch costs.
Device clocks still advance once after a successful machine step, not after each timing event.
`Cpu::step_timed` updates the queue and returns costs without advancing devices.
`Cpu::step` uses the same path when prefetch is enabled, but discards returned costs.
With prefetch disabled, the CPU-only path retains its previous untimed behavior.
Host reads, setup writes, and `Memory::advance_cycles` do not supply queue progress.
Host WAITCNT writes do apply queue configuration.
Cold entry, debugger invalidation, and PC/state mismatches clear staged queue metadata.
They preserve the existing nominal S startup policy and add no current/decode fill charge.
Accepted IRQ entry uses the interrupted pipeline's cold/retained state before replacing that pipeline.
Failed CPU steps preserve the previously committed queue.
### WAITCNT changes
A store pays source and data costs using the previous settings.
After the write, changing ROM wait fields or bit 14 clears timing metadata immediately.
Changing only SRAM or PHI fields preserves the stream.
CPU, host byte/halfword/word, and DMA writes use this configuration policy.
This reset policy is deliberate, not a verified model of live hardware reconfiguration.
Jgenesis models retained entries after disable; that behavior is not implemented here.
Exact enable/disable transitions and changes during a partial transfer need independent tests.
### DMA and idle
A DMA unit stages queue progress independently of the CPU transaction.
Its existing two startup cycles precede source and destination accesses in the nominal model.
RAM-only DMA supplies idle cartridge time. Cartridge DMA cancels the stream and can incur the completion-edge stall.
A failed unit preserves queue state and clocks. A successful WAITCNT destination applies the new configuration.
DMA's CPU-resume N request remains separate; a matching queue entry can still satisfy that request.
`HaltIdle` advances an active queue with its idle cycles. `StopIdle` freezes it.
Machine device updates do not advance the queue a second time.
Exact DMA startup/completion placement, bus arbitration, and hardware HALT/STOP edges remain unverified.
## Original regression coverage ## Original regression coverage
Test builds retain events from the last successful CPU timing transaction. Queue tests cover all ROM windows, all N/S settings, ARM/Thumb widths, partial words, capacity, and restart.
Production builds retain only totals, the code WAITCNT snapshot, and data-sequence state; they allocate no event log. They check every cancellation phase around a fill, exact-head matching, data cancellation, and page/window boundaries.
The test log covers the maximum supported block load, including PC, without truncation. Configuration tests distinguish relevant fields from PHI/SRAM fields.
Tests inspect event order and metadata independently of total cycle assertions. A seeded batched-versus-single-clock comparison checks progression consistency, not independent hardware correctness.
Coverage includes: Integration tests cover RAM loads, register shifts, ROM data stalls, branch hits/misses, and PC-load refills.
They check timed/CPU-only equivalence, debugger invalidation, IRQ entry, WAITCNT writes, DMA, HALT, and STOP.
Failure tests cover late block loads, missing ROM bytes, failed DMA, retries, and host isolation.
Instruction-pair matrices separately assert CPU N/S kinds and prefetch-adjusted costs.
Existing ordered-event tests retain source/data/internal/refill ordering checks, including a full-register LDM with PC.
Test builds retain an event log; production builds allocate no event log or queue storage on the heap.
- Word PC loads and the full register-list LDM sequence. ## Validation and remaining limits
- Thumb POP with word data and halfword target fetches.
- ARM-to-Thumb BX, taken branches to fallthrough, and skipped conditions.
- Swaps, register-shift internal cycles, and absence of duplicate source/refill events.
- WAITCNT stores with old source settings and new settings on the next fetch.
- IRQ entry after a Thumb load, including the retained non-sequential source kind.
- Failed data accesses, late block-load failure, missing current/lookahead slots, and retry isolation.
- Host reads, untimed CPU execution, and DMA isolation from the CPU trace.
## Validation
Workspace and core/demo tests pass in debug and release on Darwin arm64. Workspace and core/demo tests pass in debug and release on Darwin arm64.
Formatting, clippy, rustdoc, Python preparation tests, native ROM windows, and all graphics smoke modes pass. Formatting, clippy, rustdoc, Python preparation tests, native ROM windows, and all graphics smoke modes pass.
The release executable is native Mach-O arm64. The release executable is native Mach-O arm64.
Public ARM, Thumb, memory, and BIOS reports match the preceding sequencing change exactly, including cycles and step counts. Public ARM, Thumb, memory, and BIOS reports match the preceding ordered-timing change exactly, including cycles and step counts.
Debug and release reports also match. Original CPU and timer demo traces are unchanged. Debug and release reports also match. Original CPU and timer demo traces are unchanged.
These public checkpoints do not independently validate active prefetch timing.
## Next implementation work Next, validate this queue against independent timing tests, especially full-buffer restart, cancellation, and page boundaries.
Cold-start timing, live WAITCNT reconfiguration, per-access device scheduling, and sub-instruction DMA arbitration remain incomplete.
Implement and test queue progress against these ordered timing events before enabling WAITCNT acceleration. Exact bus history, display contention, configurable EWRAM timing, and timer/IRQ delays remain separate work.
Keep the cartridge queue separate from CPU instruction retention, BIOS protected-read history, and IWRAM local lanes.
Resolve startup, capacity, page boundaries, partial ARM fetches, cancellation, and WAITCNT changes with independent expectations.
DMA/idle progression and diagnostic rollback need separate tests before claiming those interactions are supported.

View file

@ -117,6 +117,7 @@ The stopped counter therefore increases by five even though the final machine-cy
- Cold current/decode fills have no separate startup timing charge. - Cold current/decode fills have no separate startup timing charge.
- BIOS retained history is not a complete refill/data-access bus latch. - BIOS retained history is not a complete refill/data-access bus latch.
- Missing instruction bytes remain diagnostics, not hardware prefetch aborts. - Missing instruction bytes remain diagnostics, not hardware prefetch aborts.
- Game Pak prefetch, per-access device updates, display contention, configurable EWRAM timing, and sub-instruction DMA arbitration remain unimplemented. - Per-access device updates, display contention, configurable EWRAM timing, and sub-instruction DMA arbitration remain unimplemented.
- The later [nominal prefetch queue](gamepak-prefetch.md) can change source and target costs when bit 14 is enabled.
The next timing work is Game Pak prefetch with verified startup and invalidation rules. Game Pak prefetch followed this baseline; independent startup and invalidation timing validation remains necessary.

View file

@ -50,6 +50,7 @@ Update this file when a feature lands or a limit is removed.
- IRQ entry samples its discarded incoming-state fetch before the ARM vector pair, with local IWRAM history updates. - IRQ entry samples its discarded incoming-state fetch before the ARM vector pair, with local IWRAM history updates.
- Persistent fetch access kinds follow CPU data/internal work, compose with DMA resume, and reset after target refills. - Persistent fetch access kinds follow CPU data/internal work, compose with DMA resume, and reset after target refills.
- Game Pak wait-state control (`WAITCNT`) for all three ROM windows. - Game Pak wait-state control (`WAITCNT`) for all three ROM windows.
- Bit 14 enables a nominal eight-halfword opcode prefetch queue, with partial fetches, cancellation, and DMA/idle integration.
- Sequential/non-sequential data accesses, branch refill costs, and variable multiply timing. - Sequential/non-sequential data accesses, branch refill costs, and variable multiply timing.
- Optional caller-supplied 16 KiB BIOS mapping, with CPU read protection based on retained ARM PC+8 or aligned Thumb PC+4 words. - Optional caller-supplied 16 KiB BIOS mapping, with CPU read protection based on retained ARM PC+8 or aligned Thumb PC+4 words.
- Optional original BIOS replacement: minimal boot, SoftReset, selective RegisterRamReset, IRQ dispatch, interrupt waits, memory copy/fill, integer/fixed-point arithmetic, affine matrices, bit unpacking, LZ77/run-length/Huffman decompression, and differential filters. - Optional original BIOS replacement: minimal boot, SoftReset, selective RegisterRamReset, IRQ dispatch, interrupt waits, memory copy/fill, integer/fixed-point arithmetic, affine matrices, bit unpacking, LZ77/run-length/Huffman decompression, and differential filters.
@ -117,6 +118,8 @@ This diagnostic policy does not reproduce partial transfers during hardware data
CPU instructions, including swaps, finish before a newly requested DMA unit runs. Sub-instruction bus arbitration is not modeled. CPU instructions, including swaps, finish before a newly requested DMA unit runs. Sub-instruction bus arbitration is not modeled.
CPU/bus costs are nominal estimates, not a cycle-accurate implementation. CPU/bus costs are nominal estimates, not a cycle-accurate implementation.
The instruction buffer retains the next fetch kind; cold startup costs and per-access device scheduling remain unmodeled. The instruction buffer retains the next fetch kind; cold startup costs and per-access device scheduling remain unmodeled.
Game Pak prefetch uses source-backed queue rules, not hardware-verified timing.
Live WAITCNT transitions, page-boundary behavior, and exact DMA/idle ordering still need independent timing validation.
Graphics support includes Mode 0–5 debug snapshots and HBlank row capture with sprites, mosaic, windows, and color effects. Graphics support includes Mode 0–5 debug snapshots and HBlank row capture with sprites, mosaic, windows, and color effects.
Internal affine origins, vertical window flags, and vertical mosaic counters track line boundaries. Internal affine origins, vertical window flags, and vertical mosaic counters track line boundaries.
Horizontal window comparators retain four-cycle event history. Horizontal window comparators retain four-cycle event history.
@ -167,9 +170,9 @@ Writes to cartridge addresses return errors instead of modeling cartridge hardwa
Keep unknown history diagnostic until fetch ordering and latch ownership have independent coverage. Keep unknown history diagnostic until fetch ordering and latch ownership have independent coverage.
Validate BIOS snapshots with independent hardware tests before claiming exact refill or data-access history. Validate BIOS snapshots with independent hardware tests before claiming exact refill or data-access history.
The desktop runner still uses only the original BIOS. Keep protected reads image-derived for all supplied images. The desktop runner still uses only the original BIOS. Keep protected reads image-derived for all supplied images.
2. Add Game Pak prefetch using the ordered CPU timing path; bit 14 still does not accelerate execution. 2. Validate the active nominal Game Pak prefetch queue against independent timing tests.
Verify queue startup, full-buffer restart, page boundaries, partial ARM fetches, and invalidation before enabling it. Prioritize full-buffer restart, cancellation, page boundaries, and live WAITCNT transitions.
See [prefetch research and prerequisites](research/gamepak-prefetch.md). See [prefetch behavior and evidence](research/gamepak-prefetch.md).
Validate cold-start costs, then extend per-access device updates, bus arbitration, and timer/IRQ delays. Validate cold-start costs, then extend per-access device updates, bus arbitration, and timer/IRQ delays.
3. Validate keypad retrigger behavior with hardware tests and add remaining device registers; extend BIOS reset coverage as sound/serial support becomes available. 3. Validate keypad retrigger behavior with hardware tests and add remaining device registers; extend BIOS reset coverage as sound/serial support becomes available.
Validate STOP entry/wake edges and add external wake sources and remaining DMA device modes. Validate STOP entry/wake edges and add external wake sources and remaining DMA device modes.

View file

@ -267,9 +267,14 @@ Page/wait-window crossings, masked IRQs, host/HALT isolation, and pre-write WAIT
See [persistent fetch access kinds](research/fetch-access-sequencing.md) for evidence and public-report changes. See [persistent fetch access kinds](research/fetch-access-sequencing.md) for evidence and public-report changes.
Timing-event tests inspect source/data/internal/refill order and metadata independently of aggregate cycle costs. Timing-event tests inspect source/data/internal/refill order and metadata independently of aggregate cycle costs.
They cover the full LDM register list, PC loads, Thumb POP, state changes, skipped branches, swaps, shifts, and IRQ entry. They cover the full LDM register list, PC loads, Thumb POP, state changes, skipped branches, swaps, shifts, and IRQ entry.
WAITCNT snapshots, late failure rollback, deferred fetch errors, and host/untimed/DMA isolation have separate checks. WAITCNT snapshots, late failure rollback, deferred fetch errors, and host/DMA isolation have separate checks.
CPU-only execution joins the timing path when prefetch is enabled.
Test-only event logs do not add a production allocation or a public trace API. Test-only event logs do not add a production allocation or a public trace API.
See [prefetch prerequisites](research/gamepak-prefetch.md); these tests do not establish an active Game Pak prefetch implementation. Prefetch queue tests cover every ROM wait setting and width, partial ARM words, full-buffer stops, restart, and cancellation phases.
They also check page/window boundaries, configuration changes, and batched-versus-single-clock consistency.
Integration tests check RAM/internal progress, ROM data stalls, branch and IRQ fetches, CPU-only equivalence, and debugger invalidation.
WAITCNT CPU/host/DMA writes, RAM/cart DMA, HALT/STOP, failed steps, and missing ROM bytes have separate checks.
See [prefetch evidence and limits](research/gamepak-prefetch.md). These tests validate the nominal model, not physical-hardware accuracy.
Instruction timing tests cover arithmetic, shifts, branches, transfers, block operations, multiply, and exception returns. Instruction timing tests cover arithmetic, shifts, branches, transfers, block operations, multiply, and exception returns.
Timed and untimed execution are compared across all 65,536 Thumb encodings with fixed initial registers. Timed and untimed execution are compared across all 65,536 Thumb encodings with fixed initial registers.
Timer/IRQ tests verify that changing WAITCNT changes when an interrupt is sampled. Timer/IRQ tests verify that changing WAITCNT changes when an interrupt is sampled.