fix(timers): apply machine accesses at bus completion

This commit is contained in:
0xrsydn 2026-10-06 21:44:24 +07:00
commit bfecc87e84
20 changed files with 765 additions and 137 deletions

View file

@ -233,12 +233,13 @@ fn failed_alias_reads_preserve_register_banks_device_state_and_clock() {
}
#[test]
fn alias_io_load_reads_before_device_progress_and_irq_delivery() {
fn alias_io_load_reads_at_data_completion_before_irq_delivery() {
let mut bus = memory(0xe5b0_0004); // LDR r0,[r0,#4]!
bus.write32(TIMER_BASE, 0x00c0_fffa).unwrap();
bus.write16(IE, 8).unwrap();
bus.write16(IME, 1).unwrap();
let expected = bus.read32(TIMER_BASE).unwrap();
// Six source cycles overflow/reload the timer; the data cycle adds one tick.
let expected = 0x00c0_fffb;
let mut cpu = Cpu::new(ROM_START);
cpu.registers[0] = TIMER_BASE - 4;
let mut machine = Machine::new(cpu, bus);

View file

@ -2,6 +2,10 @@
//! Timer phase resets on enable or clock-source changes. Hardware startup delays,
//! shared prescaler phase, audio events, and interrupt delivery delays are not modeled.
mod timer_step;
pub(crate) use timer_step::TimerStep;
use timer_step::TIMER_IRQ_MASK;
use crate::{
display::{Display, DisplayPosition, CYCLES_PER_LINE, HBLANK_START, VISIBLE_LINES},
dma::{Dma, DmaError, Transfer, DMA_BASE, DMA_END},
@ -58,7 +62,7 @@ pub const HALTCNT: u32 = 0x0400_0301;
const IRQ_MASK: u16 = 0x3fff;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct Timer {
reload: u16,
counter: u16,
@ -67,6 +71,23 @@ struct Timer {
}
impl Timer {
fn read8(&self, address: u32) -> u8 {
let value = if address & 2 == 0 {
self.counter
} else {
u16::from(self.control)
};
value.to_le_bytes()[(address & 1) as usize]
}
fn write8(&mut self, address: u32, value: u8, index: usize) {
if address & 2 == 0 {
self.reload = replace_byte(self.reload, address, value);
} else if address & 1 == 0 {
self.set_control(value, index);
}
}
fn enabled(&self) -> bool {
self.control & 0x80 != 0
}
@ -162,6 +183,9 @@ impl Io {
if (DMA_BASE..=DMA_END).contains(&address) {
return Some(self.dma.read8(address));
}
if (TIMER_BASE..=TIMER_BASE + 15).contains(&address) {
return Some(self.timers[((address - TIMER_BASE) / 4) as usize].read8(address));
}
let value = match address & !1 {
DISPCNT => self.dispcnt,
GREENSWAP => u16::from(self.greenswap),
@ -176,14 +200,6 @@ impl Io {
BLDALPHA => self.effects.alpha,
KEYINPUT => self.keypad.keyinput(),
KEYCNT => self.keypad.control(),
0x0400_0100..=0x0400_010e => {
let timer = &self.timers[((address - TIMER_BASE) / 4) as usize];
if address & 2 == 0 {
timer.counter
} else {
u16::from(timer.control)
}
}
IE => self.enable,
IF => self.pending,
WAITCNT => self.waitcnt,
@ -258,13 +274,7 @@ impl Io {
}
0x0400_0100..=0x0400_010e => {
let index = ((address - TIMER_BASE) / 4) as usize;
let timer = &mut self.timers[index];
if address & 2 == 0 {
// Merge into the reload latch, never the visible counter.
timer.reload = replace_byte(timer.reload, address, value);
} else if address & 1 == 0 {
timer.set_control(value, index);
}
self.timers[index].write8(address, value, index);
}
IE => self.enable = replace_byte(self.enable, address, value) & IRQ_MASK,
WAITCNT => self.waitcnt = replace_byte(self.waitcnt, address, value) & 0x5fff,
@ -388,7 +398,29 @@ impl Io {
self.wake_if_requested();
}
pub(crate) fn advance(&mut self, cycles: u32) {
pub(crate) fn timer_step(&self) -> TimerStep {
TimerStep::new(self)
}
pub(crate) fn commit_timer_step(&mut self, step: TimerStep) {
self.timers = step.timers;
self.pending = (self.pending & !TIMER_IRQ_MASK) | step.pending;
self.wake_if_requested();
}
fn advance_timer_bank(timers: &mut [Timer; 4], cycles: u32) -> u16 {
let mut pending = 0;
let mut overflows = 0;
for (index, timer) in timers.iter_mut().enumerate() {
overflows = timer.advance(cycles, overflows);
if overflows != 0 && timer.control & 0x40 != 0 {
pending |= 1 << (3 + index);
}
}
pending
}
pub(crate) fn advance(&mut self, cycles: u32, advance_timers: bool) {
// Hardware state tracks time even when host-side frame capture is disabled.
let position = self.display.position();
let phase = u32::from(position.scanline) * CYCLES_PER_LINE + u32::from(position.line_cycle);
@ -420,13 +452,8 @@ impl Io {
let (vblank, hblank) = self.display.dma_events(cycles);
self.dma.trigger(vblank, hblank);
self.pending |= self.display.advance(cycles);
let mut overflows = 0;
for (index, timer) in self.timers.iter_mut().enumerate() {
overflows = timer.advance(cycles, overflows);
if overflows != 0 && timer.control & 0x40 != 0 {
// Pending flags latch even if IE, IME, or CPSR.I blocks delivery.
self.pending |= 1 << (3 + index);
}
if advance_timers {
self.pending |= Self::advance_timer_bank(&mut self.timers, cycles);
}
self.wake_if_requested();
}

View file

@ -0,0 +1,55 @@
//! Staged timer clocks for one machine instruction, IRQ entry, or DMA unit.
//! Other devices remain on the instruction-boundary scheduler.
use super::{Io, Timer, IF, TIMER_BASE};
pub(super) const TIMER_IRQ_MASK: u16 = 0x78;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TimerStep {
pub(super) timers: [Timer; 4],
pub(super) pending: u16,
elapsed: u32,
}
impl TimerStep {
pub(super) fn new(io: &Io) -> Self {
Self {
timers: io.timers,
pending: io.pending & TIMER_IRQ_MASK,
elapsed: 0,
}
}
pub(crate) fn advance_to(&mut self, elapsed: u32) {
assert!(elapsed >= self.elapsed, "timer step time must be monotonic");
self.pending |= Io::advance_timer_bank(&mut self.timers, elapsed - self.elapsed);
self.elapsed = elapsed;
}
/// All lanes of a bus access observe the same staged time.
pub(crate) fn read8(&self, address: u32, committed: u8) -> Option<u8> {
match address {
TIMER_BASE..=0x0400_010f => {
Some(self.timers[((address - TIMER_BASE) / 4) as usize].read8(address))
}
IF => Some((committed & !(TIMER_IRQ_MASK as u8)) | self.pending as u8),
_ => None,
}
}
/// Return true only when the staged state owns the entire register byte.
pub(crate) fn write8(&mut self, address: u32, value: u8) -> bool {
match address {
TIMER_BASE..=0x0400_010f => {
let index = ((address - TIMER_BASE) / 4) as usize;
self.timers[index].write8(address, value, index);
true
}
IF => {
self.pending &= !u16::from(value);
false // The normal I/O path must also acknowledge non-timer sources.
}
_ => false,
}
}
}

View file

@ -155,8 +155,8 @@ impl Machine {
/// Sample IRQ before executing the next instruction. GBA devices do not
/// 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 writes take effect before this bulk device update. Within-instruction
/// register timing and IRQ synchronization delays are not modeled. Opcode
/// Timer data accesses use staged bus-completion phases. Other device writes
/// precede their bulk clock update. 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.
/// Row-capture diagnostics are deferred to Memory::present_frame instead.
@ -178,15 +178,21 @@ impl Machine {
self.last_timing = timing;
return Ok(StepKind::HaltIdle);
}
let (kind, timing) = if let Some(timing) = self.cpu.take_irq_timed(&mut self.memory) {
(StepKind::IrqEntry, timing)
self.memory.begin_timer_step();
let result = if let Some(timing) = self.cpu.take_irq_timed(&mut self.memory) {
Ok((StepKind::IrqEntry, timing))
} else {
(
StepKind::Instruction,
self.cpu
.step_timed(&mut self.memory)
.map_err(MachineError::Cpu)?,
)
.map(|timing| (StepKind::Instruction, timing))
.map_err(MachineError::Cpu)
};
let (kind, timing) = match result {
Ok(result) => result,
Err(error) => {
self.memory.discard_timer_step();
return Err(error);
}
};
self.memory.complete_cpu_step(timing.total());
self.last_timing = timing;

View file

@ -11,6 +11,8 @@ mod iwram_history_tests;
#[cfg(test)]
mod prefetch_tests;
#[cfg(test)]
mod timer_step_tests;
#[cfg(test)]
mod timing_event_tests;
pub(crate) use fetch::InstructionFetch;
use iwram_bus::IwramBus;
@ -20,7 +22,7 @@ use crate::{
display::{DisplayPosition, CYCLES_PER_LINE, HBLANK_START, VISIBLE_LINES},
dma::{DmaError, DMA_BASE, DMA_END},
input::Buttons,
io::{Io, HALTCNT, KEYINPUT, POSTFLG},
io::{Io, TimerStep, HALTCNT, KEYINPUT, POSTFLG},
timing::{AccessKind, AccessWidth, CpuTiming, Prefetch, StepTiming},
video::{self, sprites::pipeline, Framebuffer, VideoError},
};
@ -82,7 +84,7 @@ struct CpuAccess {
/// Successful DMA accesses drive local lanes even before the first CPU instruction.
/// Channel data remains separate; general and sub-instruction CPU bus handoff is unmodeled.
/// Instruction buffering and supported bus history consume shared fetch samples.
/// Complete bus ownership and per-access timing remain unmodeled.
/// Timer data accesses use staged machine-step time; other per-access device timing remains unmodeled.
pub struct Memory {
external_ram: Vec<u8>,
internal_ram: Vec<u8>,
@ -94,6 +96,7 @@ pub struct Memory {
io: Io,
cycles: u64,
cpu_timing: Cell<Option<CpuTiming>>,
timer_step: Cell<Option<TimerStep>>,
gamepak_prefetch: Prefetch,
#[cfg(test)]
last_cpu_timing: Option<CpuTiming>,
@ -122,6 +125,7 @@ impl Memory {
io: Io::default(),
cycles: 0,
cpu_timing: Cell::new(None),
timer_step: Cell::new(None),
gamepak_prefetch: Prefetch::default(),
#[cfg(test)]
last_cpu_timing: None,
@ -167,10 +171,41 @@ impl Memory {
pub(crate) fn complete_cpu_step(&mut self, cycles: u32) {
// IRQ entry consumes the resume too, without executing an instruction.
self.cpu_resume_nonsequential = false;
self.advance_running_cycles(cycles);
self.complete_timer_step(cycles);
}
pub(crate) fn begin_timer_step(&self) {
debug_assert!(self.timer_step.get().is_none());
self.timer_step.set(Some(self.io.timer_step()));
}
pub(crate) fn discard_timer_step(&self) {
self.timer_step.set(None);
}
fn advance_timer_step(&self, cycles: u32) {
if let Some(mut step) = self.timer_step.get() {
step.advance_to(cycles);
self.timer_step.set(Some(step));
}
}
fn complete_timer_step(&mut self, cycles: u32) {
self.advance_timer_step(cycles);
let step = self
.timer_step
.take()
.expect("machine timer transaction is active");
self.io.commit_timer_step(step);
// Timers already reached this boundary. Advance other devices exactly once.
self.advance_running_cycles_with_timers(cycles, false);
}
fn advance_running_cycles(&mut self, cycles: u32) {
self.advance_running_cycles_with_timers(cycles, true);
}
fn advance_running_cycles_with_timers(&mut self, cycles: u32, advance_timers: bool) {
if cycles != 0 && !self.sprite_pipeline.started {
// Synthetic reset begins on row0, with no preceding row227 to prepare it.
self.prepare_sprite_row(0);
@ -182,14 +217,14 @@ impl Memory {
u32::from(position.scanline) * CYCLES_PER_LINE + u32::from(position.line_cycle);
let mut advanced = 0;
for (offset, row) in pipeline::latest_events(phase, cycles).into_iter().flatten() {
self.io.advance(offset - advanced);
self.io.advance(offset - advanced, advance_timers);
self.prepare_sprite_row(row);
advanced = offset;
}
self.io.advance(cycles - advanced);
self.io.advance(cycles - advanced, advance_timers);
} else {
// Split at display and sprite-preparation edges. CPU/DMA writes have
// already committed before these nominal clocks; no per-access timing.
// Split at display and sprite-preparation edges. Video writes have
// already committed. Machine timer transactions skip this bulk timer clock.
let mut remaining = cycles;
while remaining != 0 {
let position = self.io.display_position();
@ -202,7 +237,7 @@ impl Memory {
let until =
display_edge.min(pipeline::next_event(position.scanline, position.line_cycle));
let step = remaining.min(until);
self.io.advance(step);
self.io.advance(step, advance_timers);
remaining -= step;
if step == until {
let position = self.io.display_position();
@ -316,28 +351,6 @@ impl Memory {
AccessWidth::Byte => unreachable!("DMA is never byte-wide"),
}
.map_err(map_error)?;
let data_latch = if transfer.source < 0x0200_0000 {
// DMA cannot read BIOS or the lower unused region. Retain this channel's
// full word; never read firmware bytes or borrow CPU/another channel's data.
transfer
.data_latch
.expect("unknown blocked source rejected above")
} else {
match transfer.width {
AccessWidth::Halfword => {
u32::from(self.read16(transfer.source).map_err(map_error)?) * 0x0001_0001
}
AccessWidth::Word => self.read32(transfer.source).map_err(map_error)?,
AccessWidth::Byte => unreachable!("DMA is never byte-wide"),
}
};
// A blocked halfword read keeps both old lanes. The destination selects
// one lane; mapped halfword reads have already duplicated their source data.
let value = match transfer.width {
AccessWidth::Halfword => data_latch >> ((transfer.destination & 2) * 8),
AccessWidth::Word => data_latch,
AccessWidth::Byte => unreachable!("DMA is never byte-wide"),
};
let kind = if transfer.first {
AccessKind::NonSequential
} else {
@ -348,19 +361,50 @@ impl Memory {
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 source_cycles = prefetch.data(transfer.source, transfer.width, kind);
let destination_cycles = prefetch.data(transfer.destination, transfer.width, kind);
let timing = StepTiming {
code_cycles: 0,
data_cycles: prefetch.data(transfer.source, transfer.width, kind)
+ prefetch.data(transfer.destination, transfer.width, kind),
data_cycles: source_cycles + destination_cycles,
internal_cycles,
idle_cycles: 0,
};
self.begin_timer_step();
let result = (|| {
self.advance_timer_step(internal_cycles + source_cycles);
let data_latch = if transfer.source < 0x0200_0000 {
// Blocked reads retain channel data, not firmware or another channel's value.
transfer
.data_latch
.expect("unknown blocked source rejected above")
} else {
match transfer.width {
AccessWidth::Halfword => u32::from(self.read16(transfer.source)?) * 0x0001_0001,
AccessWidth::Word => self.read32(transfer.source)?,
AccessWidth::Byte => unreachable!("DMA is never byte-wide"),
}
};
// Blocked halfword reads retain both lanes; the destination selects a lane.
let value = match transfer.width {
AccessWidth::Halfword => data_latch >> ((transfer.destination & 2) * 8),
AccessWidth::Word => data_latch,
AccessWidth::Byte => unreachable!("DMA is never byte-wide"),
};
self.advance_timer_step(timing.total());
match transfer.width {
AccessWidth::Halfword => self.write16(transfer.destination, value as u16),
AccessWidth::Word => self.write32(transfer.destination, value),
AccessWidth::Byte => unreachable!("DMA is never byte-wide"),
}?;
Ok::<_, MemoryError>((data_latch, value))
})();
let (data_latch, value) = match result {
Ok(result) => result,
Err(error) => {
self.discard_timer_step();
return Err(map_error(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;
@ -373,7 +417,7 @@ impl Memory {
.dma_access(transfer.destination, transfer.width, value);
// Keep this channel active during its cycles: another edge must not
// queue a second block while the current block is still transferring.
self.advance_cycles(timing.total());
self.complete_timer_step(timing.total());
self.io.complete_dma_unit(channel, data_latch);
self.cpu_resume_nonsequential = true;
Ok(Some((channel, timing)))
@ -566,6 +610,7 @@ impl Memory {
if let Some(mut trace) = self.cpu_timing.get() {
trace.code(address, width, kind);
self.cpu_timing.set(Some(trace));
self.advance_timer_step(trace.total.total());
}
}
@ -576,6 +621,7 @@ impl Memory {
if let Some(mut trace) = self.cpu_timing.get() {
trace.internal(cycles);
self.cpu_timing.set(Some(trace));
self.advance_timer_step(trace.total.total());
}
}
@ -583,12 +629,13 @@ impl Memory {
if let Some(mut trace) = self.cpu_timing.get() {
trace.data(self.waitcnt(), address, width);
self.cpu_timing.set(Some(trace));
self.advance_timer_step(trace.total.total());
}
}
pub fn read8(&self, address: u32) -> Result<u8, MemoryError> {
let value = self.read_byte(address)?;
self.record_access(address, AccessWidth::Byte);
let value = self.read_byte(address)?;
self.iwram_bus
.access(address, AccessWidth::Byte, u32::from(value));
Ok(value)
@ -596,6 +643,13 @@ impl Memory {
// Multi-byte reads use this helper to avoid charging each byte as a bus access.
fn read_byte(&self, address: u32) -> Result<u8, MemoryError> {
if address >> 24 == 0x04 {
if let Some(step) = self.timer_step.get() {
if let Some(value) = step.read8(address, self.io.read8(address).unwrap_or(0)) {
return Ok(value);
}
}
}
if address < BIOS_SIZE as u32
&& self.bios.is_some()
&& self
@ -694,6 +748,13 @@ impl Memory {
if matches!(byte_address, POSTFLG | HALTCNT) && !self.can_write_power_control() {
continue; // BIOS-only CPU writes; ignored elsewhere, including STOP requests.
}
if let Some(mut step) = self.timer_step.get() {
let handled = step.write8(byte_address, byte);
self.timer_step.set(Some(step));
if handled {
continue;
}
}
self.io.write8(byte_address, byte);
}
if address & !3 == crate::io::WAITCNT {
@ -786,8 +847,8 @@ impl Memory {
if address & 1 != 0 {
return Err(MemoryError::Unaligned(address));
}
let value = u16::from_le_bytes([self.read_byte(address)?, self.read_byte(address + 1)?]);
self.record_access(address, AccessWidth::Halfword);
let value = u16::from_le_bytes([self.read_byte(address)?, self.read_byte(address + 1)?]);
self.iwram_bus
.access(address, AccessWidth::Halfword, u32::from(value));
Ok(value)
@ -798,13 +859,13 @@ impl Memory {
if address & 3 != 0 {
return Err(MemoryError::Unaligned(address));
}
self.record_access(address, AccessWidth::Word);
let value = u32::from_le_bytes([
self.read_byte(address)?,
self.read_byte(address + 1)?,
self.read_byte(address + 2)?,
self.read_byte(address + 3)?,
]);
self.record_access(address, AccessWidth::Word);
self.iwram_bus.access(address, AccessWidth::Word, value);
Ok(value)
}

View file

@ -0,0 +1,346 @@
//! Original checks of timer transaction ownership and bus-completion sampling.
use super::{timing_event_tests::prepared, *};
use crate::{
cpu::Cpu,
display::HBLANK_START,
dma::DMA_STRIDE,
io::{DISPSTAT, HALTCNT, IE, IF, IME, TIMER_BASE, WAITCNT},
machine::{Machine, StepKind},
};
const NOP: u32 = 0xe1a0_0000;
const RAM: u32 = 0x0300_1000;
fn start(memory: &mut Memory, index: u32, reload: u16, control: u16) {
memory
.write32(
TIMER_BASE + index * 4,
u32::from(reload) | u32::from(control) << 16,
)
.unwrap();
}
fn dma(memory: &mut Memory, source: u32, destination: u32, count: u16) {
let base = DMA_BASE + 3 * DMA_STRIDE;
memory.write32(base, source).unwrap();
memory.write32(base + 4, destination).unwrap();
memory
.write32(base + 8, 0x8400_0000 | u32::from(count))
.unwrap();
}
#[test]
fn arm_and_thumb_timer_loads_observe_source_and_data_but_not_trailing_internal_cycles() {
for (thumb, instruction, sample, final_count) in
[(false, 0xe591_0000, 7, 8), (true, 0x6808, 4, 5)]
{
let (cpu, mut memory) = prepared(thumb, &[instruction], &[(1, TIMER_BASE)]);
start(&mut memory, 0, 0, 0x80);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.cpu().registers()[0], 0x0080_0000 | sample);
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), final_count);
assert_eq!(machine.cycles(), u64::from(final_count));
assert!(machine.memory().timer_step.get().is_none());
}
}
#[test]
fn byte_halfword_and_word_lanes_share_one_completed_access() {
for (instruction, address, expected) in [
(0xe5d1_0000, TIMER_BASE, 6),
(0xe5d1_0000, TIMER_BASE + 1, 1),
(0xe1d1_00b0, TIMER_BASE, 0x106),
(0xe591_0000, TIMER_BASE, 0x0080_0106),
] {
let (cpu, mut memory) = prepared(false, &[instruction], &[(1, address)]);
start(&mut memory, 0, 0xff, 0x80);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.cpu().registers()[0], expected);
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 0x107);
assert_eq!(machine.cycles(), 8);
}
}
#[test]
fn starting_and_stopping_timers_take_effect_after_the_store_bus_cycle() {
for running in [false, true] {
let control = if running { 0 } else { 0x0080_0000 };
let (cpu, mut memory) =
prepared(false, &[0xe581_0000, NOP], &[(0, control), (1, TIMER_BASE)]);
if running {
start(&mut memory, 0, 0, 0x80);
}
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.cycles(), 7);
assert_eq!(
machine.memory().read16(TIMER_BASE).unwrap(),
if running { 7 } else { 0 }
);
machine.step().unwrap(); // Store left N: this ROM fetch costs eight cycles.
assert_eq!(
machine.memory().read16(TIMER_BASE).unwrap(),
if running { 7 } else { 8 }
);
assert_eq!(machine.cycles(), 15);
}
}
#[test]
fn block_timer_accesses_keep_distinct_word_phases() {
let (cpu, mut memory) = prepared(false, &[0xe891_0005], &[(1, TIMER_BASE)]); // LDM r1,{r0,r2}
start(&mut memory, 0, 0, 0x80);
start(&mut memory, 1, 0, 0x80);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.cpu().registers()[0], 0x0080_0007);
assert_eq!(machine.cpu().registers()[2], 0x0080_0008);
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 9);
assert_eq!(machine.memory().read16(TIMER_BASE + 4).unwrap(), 9);
let (cpu, memory) = prepared(
false,
&[0xe880_0006],
&[(0, TIMER_BASE), (1, 0x0080_0000), (2, 0x0080_0000)],
);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 1);
assert_eq!(machine.memory().read16(TIMER_BASE + 4).unwrap(), 0);
assert_eq!(machine.cycles(), 8);
}
#[test]
fn reload_changes_preserve_pre_write_overflows_and_prescaler_phase() {
// Overflow during source fetch uses the old reload, not the value stored afterward.
let (cpu, mut memory) = prepared(false, &[0xe1c1_00b0], &[(0, 0x1234), (1, TIMER_BASE)]);
start(&mut memory, 0, 0xfffa, 0xc0);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 0xfffb);
assert_eq!(machine.memory().read16(IF).unwrap(), 8);
machine.memory_mut().advance_cycles(5);
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 0x1234);
// A read crossing a prescaler edge must retain the remainder for the next step.
let (cpu, mut memory) = prepared(false, &[0xe591_0000], &[(1, TIMER_BASE)]);
start(&mut memory, 0, 0, 0x81);
memory.advance_cycles(60);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.cpu().registers()[0], 0x0081_0001);
machine.memory_mut().advance_cycles(59);
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 1);
machine.memory_mut().advance_cycles(1);
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 2);
}
#[test]
fn cascades_and_timer_irq_bits_are_visible_at_the_read_phase() {
let (cpu, mut memory) = prepared(false, &[0xe591_0000], &[(1, TIMER_BASE + 4)]);
start(&mut memory, 0, 0xfffe, 0xc0);
start(&mut memory, 1, 0xfffe, 0xc4);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap(); // Source+data=7 gives three timer0 pulses and one timer1 overflow.
assert_eq!(machine.cpu().registers()[0], 0x00c4_ffff);
assert_eq!(machine.memory().read16(TIMER_BASE + 4).unwrap(), 0xfffe);
assert_eq!(machine.memory().read16(IF).unwrap(), 0x18);
let (cpu, mut memory) = prepared(false, &[0xe591_0000], &[(1, IE)]);
start(&mut memory, 0, 0xfffa, 0xc0);
memory.write16(IE, 8).unwrap();
memory.write16(IME, 1).unwrap();
let mut machine = Machine::new(cpu, memory);
assert_eq!(machine.step().unwrap(), StepKind::Instruction);
assert_eq!(machine.cpu().registers()[0], 0x0008_0008); // Word IE+IF, coherent timer flag.
assert_eq!(machine.step().unwrap(), StepKind::IrqEntry);
}
#[test]
fn if_acknowledgement_clears_earlier_timer_events_but_not_later_ones() {
for (instruction, reload, expected) in [(0xe1c1_00b0, 0xfffa, 0), (0xe141_0092, 0xffff, 8)] {
let (cpu, mut memory) = prepared(false, &[instruction], &[(0, 8), (1, IF), (2, 8)]);
start(&mut memory, 0, reload, 0xc0);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.memory().read16(IF).unwrap(), expected);
if instruction == 0xe141_0092 {
// SWPB acknowledges at the second data access; its final I cycle raises IF again.
assert_eq!(machine.cpu().registers()[0], 8);
}
}
}
#[test]
fn failed_instructions_discard_partial_timer_progress_and_pending_irqs() {
for (instruction, base) in [
(0xe591_0000, 0x0e00_0000),
(0xe891_0005, TIMER_BASE + 12),
(0xe881_0005, TIMER_BASE + 12),
(0xffff_ffff, 0),
] {
let (cpu, mut memory) = prepared(false, &[instruction], &[(1, base)]);
start(&mut memory, 0, 0xfffc, 0xc0);
let before = memory.io.timer_step();
let cpu_before = cpu.clone();
let mut machine = Machine::new(cpu, memory);
for _ in 0..2 {
assert!(machine.step().is_err());
assert_eq!(machine.memory().io.timer_step(), before);
assert_eq!(machine.cpu(), &cpu_before);
assert_eq!(machine.cycles(), 0);
assert!(machine.memory().timer_step.get().is_none());
assert!(machine.memory().cpu_timing.get().is_none());
}
}
// A missing current instruction fails before CpuTiming starts, but must still discard TimerStep.
let mut memory = Memory::new(Vec::new()).unwrap();
start(&mut memory, 0, 0xffff, 0xc0);
let mut machine = Machine::new(Cpu::new(ROM_START), memory);
assert!(machine.step().is_err());
assert!(machine.memory().timer_step.get().is_none());
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 0xffff);
}
#[test]
fn cpu_only_apis_and_host_reads_do_not_advance_timer_devices() {
for prefetch in [0, 0x4000] {
for timed in [false, true] {
let (mut cpu, mut memory) = prepared(false, &[0xe591_0000], &[(1, TIMER_BASE)]);
memory.write16(WAITCNT, prefetch).unwrap();
start(&mut memory, 0, 123, 0x80);
let before = memory.io.timer_step();
if timed {
cpu.step_timed(&mut memory).unwrap();
} else {
cpu.step(&mut memory).unwrap();
}
assert_eq!(cpu.registers()[0], 0x0080_007b);
assert_eq!(memory.read16(TIMER_BASE).unwrap(), 123);
assert_eq!(memory.io.timer_step(), before);
assert_eq!(memory.cycles(), 0);
}
}
}
#[test]
fn dma_samples_timers_after_startup_and_source_and_writes_after_destination() {
let mut memory = Memory::new(Vec::new()).unwrap();
start(&mut memory, 0, 0, 0x80);
dma(&mut memory, TIMER_BASE, RAM, 1);
memory.step_dma().unwrap().unwrap();
assert_eq!(memory.read32(RAM).unwrap(), 0x0080_0003);
assert_eq!(memory.read16(TIMER_BASE).unwrap(), 4);
assert_eq!(memory.cycles(), 4);
let mut memory = Memory::new(Vec::new()).unwrap();
memory.write32(RAM, 0x0080_0000).unwrap();
memory.write32(RAM + 4, 0x0080_0000).unwrap();
dma(&mut memory, RAM, TIMER_BASE, 2);
memory.step_dma().unwrap().unwrap();
assert_eq!(memory.read16(TIMER_BASE).unwrap(), 0);
memory.step_dma().unwrap().unwrap();
assert_eq!(memory.read16(TIMER_BASE).unwrap(), 2);
assert_eq!(memory.read16(TIMER_BASE + 4).unwrap(), 0);
assert_eq!(memory.cycles(), 6);
}
#[test]
fn dma_failures_preserve_timers_and_if_and_clear_the_transaction() {
for (source, destination) in [(0x0e00_0000, RAM), (TIMER_BASE, ROM_START)] {
let mut memory = Memory::new(Vec::new()).unwrap();
start(&mut memory, 0, 0xffff, 0xc0);
let before = memory.io.timer_step();
dma(&mut memory, source, destination, 1);
assert!(memory.step_dma().is_err());
assert_eq!(memory.io.timer_step(), before);
assert!(memory.timer_step.get().is_none());
assert_eq!(memory.cycles(), 0);
}
}
#[test]
fn display_capture_splits_do_not_advance_committed_timers_twice() {
for capture in [false, true] {
let (cpu, mut memory) = prepared(false, &[0xe891_007d], &[(1, 0x0200_1000)]);
memory.set_scanline_rendering(capture);
memory.advance_cycles(HBLANK_START - 2);
start(&mut memory, 0, 0, 0x80);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap(); // Source 6, six EWRAM words 36, internal 1.
assert_eq!(machine.last_timing().total(), 43);
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 43);
assert_eq!(machine.cycles(), u64::from(HBLANK_START - 2 + 43));
}
}
#[test]
fn timer_flag_commit_preserves_other_irq_sources() {
for (instruction, expected) in [(0xe1d1_00b0, 0xa), (0xe1c1_00b0, 2)] {
let (cpu, mut memory) = prepared(false, &[instruction], &[(0, 8), (1, IF)]);
memory.write16(DISPSTAT, 0x10).unwrap();
memory.advance_cycles(HBLANK_START);
assert_eq!(memory.read16(IF).unwrap(), 2);
start(&mut memory, 0, 0xfffa, 0xc0);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.memory().read16(IF).unwrap(), expected);
if instruction == 0xe1d1_00b0 {
assert_eq!(machine.cpu().registers()[0], 0xa);
}
}
}
#[test]
fn bios_power_store_pays_its_cycles_and_new_timer_irq_wakes_only_halt() {
for stop in [false, true] {
let (mut cpu, mut memory) = prepared(
false,
&[0xe12f_ff1c],
&[(0, if stop { 0x80 } else { 0 }), (1, HALTCNT), (12, 0x100)],
);
memory.bios.as_mut().unwrap()[0x100..0x104].copy_from_slice(&0xe5c1_0000_u32.to_le_bytes());
cpu.step(&mut memory).unwrap(); // BX into original BIOS STRB r0,[r1].
start(&mut memory, 0, 0xffff, 0xc0);
memory.write16(IE, 8).unwrap();
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.cycles(), 2);
assert_eq!(machine.memory().read16(IF).unwrap(), 8);
assert!(!machine.halted());
assert_eq!(machine.stopped(), stop);
if stop {
let before = machine.memory().io.timer_step();
assert_eq!(machine.step().unwrap(), StepKind::StopIdle);
assert_eq!(machine.memory().io.timer_step(), before);
assert_eq!(machine.cycles(), 2);
} else {
assert_eq!(machine.step().unwrap(), StepKind::Instruction);
}
}
}
#[test]
fn halt_and_stop_keep_their_clock_ownership() {
for stop in [false, true] {
let mut memory = Memory::new(Vec::new()).unwrap();
start(&mut memory, 0, 0, 0x80);
memory.write8(HALTCNT, if stop { 0x80 } else { 0 }).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), memory);
assert_eq!(
machine.step().unwrap(),
if stop {
StepKind::StopIdle
} else {
StepKind::HaltIdle
}
);
assert_eq!(
u64::from(machine.memory().read16(TIMER_BASE).unwrap()),
machine.cycles()
);
assert!(machine.memory().timer_step.get().is_none());
}
}

View file

@ -93,7 +93,8 @@ pub fn bus_cycles(waitcnt: u16, address: u32, width: AccessWidth, kind: AccessKi
}
/// One speculative instruction/IRQ timing transaction. Events arrive in source,
/// data, internal, then target-pair order. Device clocks still advance in bulk.
/// data, internal, then target-pair order. Machine timers follow these events;
/// other device clocks still advance in bulk.
/// Code uses the entry WAITCNT snapshot; data uses settings at each bus access.
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct CpuTiming {

View file

@ -65,8 +65,8 @@ fn timer_demo_configures_hardware_in_cpu_code_and_handles_exactly_one_irq() {
assert_eq!(machine.cpu().registers()[10], 1);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
assert_eq!(machine.memory().read16(TIMER_BASE + 2).unwrap(), 0);
// Persistent fetch kinds move costs between setup instructions and the running timer.
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 0xfffb);
// Start no longer pays its preceding nine cycles; stop now pays its two bus cycles.
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 0xfff4);
for _ in 0..100 {
assert_eq!(machine.step().unwrap(), StepKind::Instruction);
}

View file

@ -42,20 +42,17 @@ fn observed_extra_cycle_is_in_rom_data_cancellation_not_internal_work() {
}
#[test]
fn unadjusted_timer_samples_expose_the_instruction_boundary_sampling_limit() {
// This is a known-gap regression, not an assertion that hardware behaves this way.
// Replace it when timer I/O moves to ordered bus phases; do not correct probe output.
fn unadjusted_timer_samples_match_published_observations_at_the_bus_access() {
// Keep the original program and published values unchanged. No result correction.
for (index, expected) in PUBLISHED_READ_DELTAS.into_iter().enumerate() {
for (waitcnt, expected) in WAITCNT_SETTINGS.into_iter().zip(expected) {
let control = measure(waitcnt, index as u8 + 1, false).unwrap();
let read = measure(waitcnt, index as u8 + 1, true).unwrap();
let sampled = i64::from(read.timer_sample) - i64::from(control.timer_sample);
let boundary = read.boundary_cycles as i64 - control.boundary_cycles as i64;
let endpoint_fetch_difference = i64::from(read.sample_timing.code_cycles)
- i64::from(control.sample_timing.code_cycles);
assert_ne!(sampled, i64::from(expected));
assert!(endpoint_fetch_difference > 0);
assert_eq!(boundary - sampled, endpoint_fetch_difference);
assert_eq!(sampled, i64::from(expected));
assert_eq!(boundary, sampled);
assert!(read.sample_timing.code_cycles > control.sample_timing.code_cycles);
}
}
}

View file

@ -48,6 +48,8 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/src/cpu/fetch_history_tests.rs` | Shared sample consumption, no execution-entry rereads, and failed-step retry |
| `crates/gba-core/src/cpu/fetch_boundary_tests.rs` | Actual fetch-region snapshots, split refills, IWRAM lane requirements, ROM-window boundaries, and nominal timing |
| `crates/gba-core/src/io.rs` | I/O registers, timers, HALT/STOP wake-up, next-event bounds, and IRQ latches |
| `crates/gba-core/src/io/timer_step.rs` | Staged timer bank and timer IF bits at ordered CPU/DMA bus phases |
| `crates/gba-core/src/memory/timer_step_tests.rs` | Timer access phases, coherent lanes, cascades, IF ordering, rollback, DMA, idle, and clock ownership |
| `crates/gba-core/src/display.rs` | Display clock, scanline status, comparison edges, and display IRQ events |
| `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 |
@ -69,7 +71,7 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-demos/src/timer_demo.rs` | Original timer-configuration program and IRQ handler |
| `crates/gba-demos/src/prefetch_probe.rs` | Original bounded ARM read/control probes and pinned published cancellation observations |
| `crates/gba-demos/examples/prefetch_cancellation.rs` | CSV comparison of published, instruction-boundary, and unadjusted timer intervals; fails on mismatches |
| `crates/gba-core/tests/prefetch_cancellation.rs` | Published read comparisons, cancellation phase, disabled-prefetch controls, and the known timer sampling gap |
| `crates/gba-core/tests/prefetch_cancellation.rs` | Published read comparisons, cancellation phase, disabled-prefetch controls, and unadjusted timer samples |
| `Cargo.toml` | Workspace members and shared package settings |
| `crates/gba-core/src/lib.rs` | Core modules |
| `crates/gba-demos/src/lib.rs` | Demo modules, original instruction/exception bytes, and raw input-test ROM |

View file

@ -10,9 +10,9 @@ direnv exec . cargo run --locked -p gba-demos --example prefetch_cancellation >
```
This headless original ARM probe compares published read-cancellation observations with two separate emulator measurements.
Instruction-boundary totals match, but actual timer samples currently fail. The example therefore exits with status 1.
Do not treat the matching totals as a hardware-timing pass.
See [the protocol, source hashes, and timer sampling gap](research/prefetch-cancellation.md).
Both instruction-boundary totals and actual timer samples now match; the example exits with status 0.
This is a bounded comparison with published observations, not a complete hardware-timing pass.
See [the protocol, source hashes, and timer sampling correction](research/prefetch-cancellation.md).
The probe needs no downloaded ROM, assets, window, or external assembler.
## Open the affine raster demo

View file

@ -56,7 +56,7 @@ The loaded value takes precedence over the updated base. Addresses and register
Pre-indexed loads access the adjusted address; post-indexed loads access the original address.
The discarded post-indexed writeback address is not a second memory access.
Normal alignment, word rotation, odd-halfword behavior, and sign extension still apply.
Loads preserve CPSR and use normal load timing. Device clocks advance after the read, with IRQ sampling on the next step.
Loads preserve CPSR and use normal load timing. Machine timer reads sample at data-access completion; IRQ delivery waits until the next step.
Read diagnostics leave CPU state and device clocks unchanged under the existing atomic-error policy.
Stores with matching base/source still store the original base value before writeback.
PC writeback, byte/halfword PC destinations, and unimplemented user-transfer encodings remain diagnostics.
@ -359,7 +359,8 @@ The new settings apply to subsequent code accesses.
Timed CPU execution uses an ordered `CpuTiming` transaction: source fetch, actual data accesses, internal work, then target-pair fetches.
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.
Device clocks still advance once after the successful step; this is not per-cycle scheduling.
Machine timer state advances through these events and commits only after success. Other device clocks still advance in bulk afterward.
This is not a complete per-cycle scheduler.
The transaction also stages the nominal Game Pak prefetch queue described below.
### Game Pak opcode prefetch
@ -387,7 +388,7 @@ Host reads and `Memory::advance_cycles` do not advance the queue; machine steps
These are source-backed nominal rules, not a hardware conformance claim.
See [queue evidence, regression coverage, and limits](../research/gamepak-prefetch.md).
The [original cancellation probe](../research/prefetch-cancellation.md) matches published read interval totals at instruction boundaries.
Its unadjusted timer samples still fail because timer reads precede the instruction's device-clock update.
Its unadjusted timer samples now also match after [ordered timer accesses](../research/ordered-timer-access.md).
### Nominal CPU resume after DMA

View file

@ -47,6 +47,9 @@ CPU delivery waits until no DMA is ready. IRQ entry does not acknowledge IF.
Timing uses independent source and destination bus costs, including WAITCNT and 128 KiB ROM boundaries.
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.
Timers stage progress through startup, source, and destination phases. Timer reads sample after source completion; writes apply after destination completion.
Timer IF acknowledgements use that same ordering. Success commits the staged bank; failure discards it.
Other devices then advance in bulk without ticking timers twice. Exact physical DMA startup timing remains unverified.
A successful unit breaks the next nominal CPU code-access sequence, even if DMA accessed only RAM.
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.

View file

@ -15,7 +15,7 @@ Entry charges the discarded incoming-state PC+8/PC+4 fetch and the ARM vector pa
DMA resume can make the discarded fetch non-sequential. Missing discarded bytes do not prevent IRQ entry.
See [refill timing](../research/refill-fetch-timing.md) for history effects and remaining limits.
A device event during a step can trigger IRQ entry once no DMA is ready and CPU masks allow delivery.
Bus writes take effect before the step's device-clock update.
Timer accesses use ordered bus-completion phases. Other device writes still take effect before their bulk clock update.
`StepKind::Dma { channel }` identifies a DMA unit. `StepKind::HaltIdle` identifies one bounded HALT clock advance.
`StepKind::StopIdle` identifies a stopped system with no clock progress.
The other step kinds remain `Instruction` and `IrqEntry`.
@ -23,14 +23,24 @@ The other step kinds remain `Instruction` and `IrqEntry`.
Failed steps do not advance the clock or partially change CPU or device state. Earlier successful steps remain committed.
**The clock now uses instruction-specific costs, but it is not cycle-accurate.**
Device clocks update in batches after instructions, exception entries, DMA units, or HALT idle intervals.
Reads observe register state before that batch.
Writes, including timer start/stop and IF acknowledgement, take effect before the entire batch.
This does not reproduce bus-access timing within an instruction or IRQ synchronization delays.
The [prefetch cancellation probe](../research/prefetch-cancellation.md) makes this limitation measurable.
Its timer load omits its own source-fetch time, which differs between the read and control runs.
Nominal instruction-boundary totals match published observations; the actual timer samples do not.
The diagnostic reports both values and returns failure rather than correcting the timer sample.
Timers now use a staged clock during machine instructions, IRQ entry, and DMA units.
Source-fetch cycles advance that clock before data work. Each data access advances it before reading or writing timer registers.
All byte lanes of one access share the same completion time. Internal and target-fetch cycles follow in order.
A timer load therefore includes its source and data cycles, but not its trailing internal cycle.
Timer-start writes do not count preceding cycles. Timer-stop writes count through their own bus completion.
Block transfers preserve distinct word phases; combined reload/control writes merge at one phase, with reload bytes first.
The transaction also owns timer IF bits. Reads see overflows through the current access; acknowledgements clear earlier timer requests.
A later internal cycle can raise an acknowledged timer request again. Non-timer IF bits retain their existing behavior.
IRQ delivery still waits until the next machine step. A committed timer request can wake HALT, but not STOP.
Successful steps commit timers and timer IF bits. Failed steps discard staged counters, prescaler phases, reload/control writes, and requests.
Display and other device clocks then advance once, without advancing timers again.
Those devices still use the bulk scheduler. Mid-instruction display events, general bus arbitration, and IRQ synchronization delays remain unmodeled.
Host clock advances and HALT idle batches retain ordinary bulk timer advancement; STOP idle does not advance timers.
The unchanged [prefetch cancellation probe](../research/prefetch-cancellation.md) now matches both published interval totals and actual timer samples.
No offset is added to its timer values. See [clock ownership and evidence](../research/ordered-timer-access.md) for the bounded scope.
`Cpu::step` remains a CPU-only API. It does not honor HALT/STOP, execute DMA, advance device clocks, or sample device IRQs.
`Cpu::step_timed` executes the same operation and returns `StepTiming`, without advancing devices.
@ -78,9 +88,11 @@ Audio events and interrupt sources other than timers, display events, DMA comple
`--timer-demo` executes original ARM code that configures Timer 0, IE, and IME.
The timer overflows after 16 supplied cycles and enters the original handler through vector `0x18`.
The handler stops Timer 0, acknowledges IF, increments r10, and returns with `SUBS pc, lr, #4`.
The demo ends after 21 steps and 151 nominal cycles with one handler call, using default WAITCNT settings.
The demo completes with one handler call, using default WAITCNT settings.
Its initial ARM ROM fetch uses PC+8 sequential timing; cold pipeline filling has no separate startup charge.
These counts test the timing model; they are not hardware timing measurements.
The stopped counter is now `0xfff4`, rather than the earlier bulk-write result `0xfffb`.
Timer start excludes its preceding nine cycles; timer stop includes its two bus cycles.
These are nominal regression results, not hardware startup-delay measurements.
Its test handler deliberately changes r2 and r10; it does not implement the Nintendo BIOS calling convention.
## Keypad interrupt control

View file

@ -209,12 +209,20 @@ Game Pak prefetch research:
- [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.
- [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 source at 9ca57c13](https://github.com/zaydlang/PrefetchAbuse/blob/9ca57c13da7e3c569937f99a42e7c1caca029a2d/src/main.c), its [README](https://github.com/zaydlang/PrefetchAbuse/blob/9ca57c13da7e3c569937f99a42e7c1caca029a2d/README.md), and [Makefile](https://github.com/zaydlang/PrefetchAbuse/blob/9ca57c13da7e3c569937f99a42e7c1caca029a2d/Makefile), for published read observations, actual multiply delays, and build assumptions. No upstream program or ROM was imported.
See [the original cancellation comparison](research/prefetch-cancellation.md) for hashes, correspondence limits, matching nominal intervals, and failing timer samples.
See [the original cancellation comparison](research/prefetch-cancellation.md) for hashes, correspondence limits, and the initial timer sampling failure.
Both nominal intervals and unadjusted timer samples now match after ordered timer accesses.
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.
Ordered timer data accesses:
- The pinned [Jgenesis bus at fab6e2cc](https://github.com/jsgroth/jgenesis/blob/fab6e2ccc60e492dd68b7f1e927b0829a6d80195/backend/gba-core/src/bus.rs) charges one I/O cycle before register reads/writes and passes the resulting clock to timers.
- The pinned PrefetchAbuse read observations above isolate the missing source-fetch interval in the previous bulk timer model.
See [staged timer clock ownership](research/ordered-timer-access.md). Relative probe agreement does not establish all absolute timer edges.
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.
The ignore file excludes `roms/`, common game ROM extensions, and save files.

View file

@ -24,7 +24,7 @@ Background progress cannot cause an early ROM-file lookup or change instruction
- [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 at 9ca57c13](https://github.com/zaydlang/PrefetchAbuse/blob/9ca57c13da7e3c569937f99a42e7c1caca029a2d/src/main.c) supplies published hardware read-cancellation observations.
A later [original probe](prefetch-cancellation.md) matches the instruction-boundary differences, but exposes incorrect bulk timer sampling.
The [original probe](prefetch-cancellation.md) matches instruction-boundary differences and, after ordered timer accesses, actual timer samples.
The source, README, and build assumptions were reviewed. No upstream implementation or ROM was imported.
No external implementation or ROM was added to the repository.
@ -76,7 +76,7 @@ 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.
Machine timer transactions now follow these events and commit on success. Other devices still advance in bulk after the step.
`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.
@ -135,8 +135,8 @@ Public ARM, Thumb, memory, and BIOS reports match the preceding ordered-timing c
Debug and release reports also match. Original CPU and timer demo traces are unchanged.
These public checkpoints do not independently validate active prefetch timing.
The later [cancellation comparison](prefetch-cancellation.md) matches published read observations only at instruction boundaries.
Actual timer readings still fail because device updates occur after the timer-load instruction.
Next, implement ordered timer observations and validate full-buffer restart and page boundaries independently.
The [cancellation comparison](prefetch-cancellation.md) originally matched published read observations only at instruction boundaries.
[Ordered timer accesses](ordered-timer-access.md) now make the unchanged timer readings match as well.
Next, validate absolute timer edges, full-buffer restart, and page boundaries independently.
Cold-start timing, live WAITCNT reconfiguration, per-access device scheduling, and sub-instruction DMA arbitration remain incomplete.
Exact bus history, display contention, configurable EWRAM timing, and timer/IRQ delays remain separate work.

View file

@ -0,0 +1,100 @@
# Ordered timer data accesses
## Result
Machine timer reads and writes now use bus-access completion times instead of the instruction's initial clock.
The unchanged [prefetch cancellation probe](prefetch-cancellation.md) matches both published interval totals and unadjusted timer readings.
Its program, published observations, and measurement code were not changed to obtain this result.
This is a timer-specific extension to nominal timing, not a complete cycle-accurate device scheduler.
Display events, DMA arbitration, timer startup delays, and IRQ synchronization remain separate limits.
No physical-hardware measurement or external-emulator differential run was performed locally.
## Evidence and phase choice
The preceding probe isolated the error: its missing interval equaled the difference between the final source-fetch costs.
The opcode queue computed those costs correctly, but the timer read occurred before they advanced device time.
Moving only the probe's reported result would conceal that error rather than correct device behavior.
The reviewed [Jgenesis bus at fab6e2cc](https://github.com/jsgroth/jgenesis/blob/fab6e2ccc60e492dd68b7f1e927b0829a6d80195/backend/gba-core/src/bus.rs) charges the I/O cycle before register access.
Its timer reads and writes receive the resulting clock value.
This supports the implemented completion-phase rule for a one-cycle I/O access.
No source code was imported from that implementation.
The published subtraction experiment validates relative read intervals.
It does not independently establish every absolute timer edge, startup delay, or control-write phase.
Those remain explicit validation limits.
## Staged clock ownership
`Memory` owns an optional `TimerStep` during each machine instruction, IRQ entry, or DMA unit.
The transaction copies only the four timers and timer IF bits, not RAM, display state, or the entire I/O object.
Its elapsed timestamp starts at zero and increases monotonically.
It has no heap allocation or event log.
For a CPU instruction:
1. Stage timer progress through the source fetch.
2. For each data access, stage its elapsed cycles before reading or writing timer registers.
3. Stage internal cycles and any target-pair fetches in their existing order.
4. On success, commit timers and timer IF bits at the final timestamp.
5. Advance the other devices and the machine clock once, without ticking timers again.
One word access has one sampling time for all four byte lanes.
Reload bytes merge before control bytes at that same time.
Block transfers have separate completion times for successive words.
A start write cannot count earlier source cycles. A stop write includes clocks through its completion.
Prescaler remainders, cascaded overflow pulses, and reload behavior use the existing timer implementation.
Timer IF reads include requests raised through the access phase.
Acknowledgement clears timer requests already present at that phase; later work can raise them again.
Committing timer flags preserves non-timer IF bits.
Those other sources still follow the existing bulk event model, so IF is not a fully cycle-accurate cross-device snapshot.
IRQ delivery remains at machine-step boundaries, never halfway through an instruction.
A committed enabled timer request wakes HALT. STOP does not use timer requests as a wake source.
### DMA
Each unit uses the existing nominal two-cycle startup on its first transfer.
Timer sampling follows startup plus source cost; destination writes follow the destination cost.
A failed source or destination discards the timer transaction.
A successful unit commits timer state before other device clocks and DMA completion IRQ handling.
This extends the existing whole-unit scheduler; it does not establish physical DMA startup or preemption timing.
### Errors and API isolation
CPU diagnostics discard staged time, counter values, prescaler remainders, and pending timer requests.
This includes failures before instruction fetch and failures after earlier block-load accesses.
Existing store validation still prevents partial writes when a block transfer has an invalid destination.
Previously completed machine steps remain committed.
`Cpu::step` and `Cpu::step_timed` remain CPU-only APIs.
They update CPU state, ordinary register writes, instruction retention, and enabled opcode-prefetch timing as before.
They do not create a timer transaction or advance device clocks.
Host reads and writes likewise remain clock-free.
`Memory::advance_cycles` and HALT idle intervals use the existing bulk timer advancement.
STOP idle freezes all clocks. The instruction entering STOP still pays its complete nominal duration.
Instruction fetches from I/O are not given the data-access timer sampling rule.
Other device reads, writes, rendering, and event scheduling retain their documented limits.
## Validation
Original tests cover ARM/Thumb loads, coherent byte lanes, start/stop stores, and ordered block transfers.
They check old-reload overflow behavior, prescaler retention, cascades, timer IF reads, acknowledgement, and later reassertion.
Other tests cover non-timer IF preservation, deferred IRQ delivery, DMA phases, rollback, and CPU-only isolation.
Capture-enabled and capture-disabled steps check that display clock splits do not tick timers twice.
BIOS HALT/STOP stores check final-cycle ownership and timer wake behavior.
The cancellation example exits successfully in debug and release on Darwin arm64.
Its CSV reports match between build profiles. No correction is applied to loaded timer values.
Workspace/core/demo tests, formatting, clippy, rustdoc, Python tests, native ROM windows, and graphics smoke modes pass.
Pinned public ARM, Thumb, memory, and BIOS reports remain byte-identical to the preceding change.
The original timer demo retains its cycle count and IRQ-entry step.
Its stopped counter changes from `0xfffb` to `0xfff4`: start excludes nine preceding cycles, while stop includes two bus cycles.
The alias-load regression now samples `0x00c0fffb` after source and data cycles, instead of the previous `0x00c0fffa`.
These expectation changes follow the access phases; the independent cancellation program and observations remain unchanged.
Next, validate absolute timer startup/control edges and independently test full-buffer prefetch restart and page boundaries.
Extend other device observations only with explicit clock ownership and rollback coverage.

View file

@ -4,9 +4,9 @@
An original ARM probe reproduces the measured structure of the read cases in `zaydlang/PrefetchAbuse`.
Its instruction-boundary cycle differences match every published read observation.
Its actual emulated timer readings do **not** match those observations.
The difference isolates an existing timer-sampling limitation, not a new queue discrepancy.
No core timing behavior was changed for this comparison.
Its actual emulated timer readings now also match, after [ordered timer accesses](ordered-timer-access.md).
The initial comparison isolated a bulk timer-sampling error, not a queue discrepancy.
The original program, published observations, and measurement code remain unchanged.
This is not a run or a port of the upstream ROM.
The expected values are published measurement facts. The program builder and runner are original.
@ -70,10 +70,10 @@ direnv exec . cargo test --locked -p gba-core --test prefetch_cancellation
direnv exec . cargo run --locked -p gba-demos --example prefetch_cancellation > /tmp/prefetch-cancellation.csv
```
**The example currently exits with status 1 because the timer comparisons fail.**
That status is expected evidence of a known limit, not a passing hardware-timing test.
A matching instruction-boundary total cannot override a failed timer comparison.
Status 0 requires both comparisons to match for every case. Status 2 reports invalid arguments or execution errors.
**The example now exits with status 0 because both comparisons match.**
It previously exited with status 1, exposing the bulk timer-sampling error described below.
A matching instruction-boundary total still cannot override a failed timer comparison.
Status 0 requires both comparisons to match for every case. Status 1 reports mismatches; status 2 reports invalid arguments or execution errors.
The example takes no arguments and needs no downloaded files, window, or external assembler.
Use `--release` to repeat the same checks in a release build.
@ -90,11 +90,11 @@ The CSV columns are:
No timing correction is applied to `timer_delta`.
The runner does not hide, patch, or reinterpret failing timer values.
## Root cause of the timer mismatch
## Historical timer mismatch and correction
The opcode queue sees ordered source, data, and internal costs.
The timer device still updates only after the entire instruction completes.
Consequently, the timer load reads the old counter before its own source-fetch cost advances device time.
Before the correction, the opcode queue saw ordered source, data, and internal costs.
The timer device updated only after the entire instruction completed.
Consequently, the timer load read the old counter before its own source-fetch cost advanced device time.
The read run cancels prefetch with ROM data traffic.
Its subsequent timer load must fetch code from ROM again.
@ -107,22 +107,24 @@ For a one-cycle multiply and WAITCNT `0x4000`:
| --- | ---: |
| Published interval difference | 17 |
| Machine-clock boundary difference | 17 |
| Actual timer-sample difference | 14 |
| Previous timer-sample difference | 14 |
| Current timer-sample difference | 17 |
| Timer-load source fetch in the read run | 8 |
| Timer-load source fetch in the control run | 5 |
The missing three cycles equal `8 - 5`.
For every compared case, the boundary-minus-timer discrepancy equals the final source-fetch cost difference.
Before the correction, each boundary-minus-timer discrepancy equaled the final source-fetch cost difference.
The sample instructions have identical data and internal costs, so those costs cancel in the subtraction.
With prefetch disabled, the two final source-fetch costs match.
The timer and boundary differences then match each other, and the cancellation-specific extra cycle disappears.
This negative control helps separate the sampling problem from timer subtraction or multiply decoding errors.
The regression tests explicitly mark the bulk-sampling expectation as a known gap.
Replace that expectation when timer I/O moves to ordered bus phases.
Do not make the probe pass by adding an offset to its loaded timer value.
Timer startup/stop placement, other device events, and atomic failure behavior also need coverage before changing clock ownership.
Timer data accesses now sample staged device state at bus completion, including the source and data cycles.
Timer writes use the same phase. Successful steps commit that state; failed steps discard it.
The regression now requires actual timer values to match the unchanged published observations.
No offset was added to the loaded value or diagnostic report.
See [ordered timer accesses](ordered-timer-access.md) for start/stop, IF, DMA, API isolation, and rollback coverage.
## Coverage and limits
@ -139,5 +141,6 @@ The largest multiply delay here does not establish eight-halfword capacity behav
Live WAITCNT changes, DMA, HALT/STOP edges, and exact hardware timer startup delays remain outside this probe.
See [the queue model](gamepak-prefetch.md) for those separate limits.
Next, move timer observations toward ordered bus-access phases with explicit rollback and no duplicate device advancement.
Ordered timer observations now retain rollback and avoid duplicate device advancement.
Absolute timer startup and control-edge validation remain necessary.
Independent full-buffer and page-boundary measurements remain necessary before claiming complete prefetch timing accuracy.

View file

@ -51,7 +51,8 @@ Update this file when a feature lands or a limit is removed.
- 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.
- Bit 14 enables a nominal eight-halfword opcode prefetch queue, with partial fetches, cancellation, and DMA/idle integration.
- An original ARM cancellation probe matches published read interval totals, while separately reporting incorrect bulk timer samples.
- Staged timer clocks follow CPU and DMA bus-completion phases, including timer IF reads/acknowledgements and failed-step rollback.
- The unchanged original ARM cancellation probe matches both published read interval totals and its unadjusted timer samples.
- 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 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.
@ -121,7 +122,7 @@ 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.
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.
Timer reads occur before the current instruction's device update. Prefetch cancellation probes expose the resulting sample error.
Timer data accesses follow ordered nominal bus phases. Other devices remain instruction-batched; exact timer startup and IRQ delays remain unverified.
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.
Horizontal window comparators retain four-cycle event history.
@ -173,8 +174,8 @@ Writes to cartridge addresses return errors instead of modeling cartridge hardwa
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.
2. Validate the active nominal Game Pak prefetch queue against independent timing tests.
The [cancellation probe](research/prefetch-cancellation.md) isolates a timer sampling gap despite matching nominal interval totals.
Move timer observations to ordered bus phases without losing rollback or advancing devices twice.
The [cancellation probe](research/prefetch-cancellation.md) now matches after [ordered timer accesses](research/ordered-timer-access.md).
Validate absolute timer startup/control edges and extend ordered observations to other devices without duplicate clocks or lost rollback.
Validate full-buffer restart, page boundaries, live WAITCNT transitions, and cold-start costs.
See [prefetch behavior and evidence](research/gamepak-prefetch.md); exact bus arbitration and timer/IRQ delays remain separate work.
3. Validate keypad retrigger behavior with hardware tests and add remaining device registers; extend BIOS reset coverage as sound/serial support becomes available.

View file

@ -276,13 +276,17 @@ Integration tests check RAM/internal progress, ROM data stalls, branch and IRQ f
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.
An [original cancellation probe](research/prefetch-cancellation.md) compares read/control intervals with pinned, published hardware observations.
Its instruction-boundary totals match; its actual timer samples fail. The example returns nonzero for those failures.
Regressions identify the missing source-fetch interval without applying a correction to the timer values.
Both its instruction-boundary totals and actual timer samples now match. Any mismatch still returns a nonzero status.
Regressions retain the original program and published observations without correcting the loaded timer values.
Disabled-prefetch controls, delay bounds, completion PCs, and fresh-run determinism have separate checks.
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.
Timer/IRQ tests verify that changing WAITCNT changes when an interrupt is sampled.
Timer tests cover all prescalers, reload changes, start/stop behavior, byte writes, large clock advances, and cascades.
Ordered timer tests cover ARM/Thumb sampling, coherent byte lanes, block-transfer phases, reload/control ordering, and prescaler retention.
They verify timer IF reads/acknowledgements, preservation of other IF bits, deferred IRQ delivery, DMA phases, and HALT/STOP ownership.
Failures before fetch and after partial data work must discard staged time and requests. CPU-only APIs must not advance timer devices.
Display-capture splits must not tick committed timers a second time. See [ordered timer accesses](research/ordered-timer-access.md).
Bulk timer advancement is compared against an independent cycle-by-cycle reference for each prescaler and cascade configuration.
Machine tests cover ARM/Thumb IRQ return, pending-request gating, acknowledgement, and the complete timer demo.
Failed I/O block transfers are checked for partial register writes, hidden reload changes, and unintended IF acknowledgement.