feat(timing): break CPU sequential access after DMA

Retain one pending non-sequential resume after successful DMA. Apply it to the next nominal non-refill code access and consume it only on successful CPU or IRQ progress. Preserve failed-step atomicity, host and idle isolation, existing refill costs, and WAITCNT ordering.

Add 13 original regressions for ARM/Thumb widths, ROM wait settings, sources, preemption, retries, mixed APIs, idle states, and interrupt timing. Record pinned hardware-test source and emulator evidence without claiming physical-hardware or full-pipeline conformance.

Validated on Darwin arm64: 952 workspace and 883 core/demo tests per debug/release profile, 39 Python tests, native ROM windows, ten window smoke modes, fmt, clippy, and rustdoc. Public ARM/Thumb/memory/BIOS reports remain unchanged.
This commit is contained in:
0xrsydn 2026-10-06 03:12:54 +07:00
commit 16286e0cbd
11 changed files with 437 additions and 4 deletions

View file

@ -18,6 +18,8 @@ mod bios_access_tests;
#[cfg(test)]
mod compare_psr_tests;
#[cfg(test)]
mod dma_resume_tests;
#[cfg(test)]
mod exception_tests;
#[cfg(test)]
mod instruction_tests;

View file

@ -0,0 +1,302 @@
//! Original tests of the nominal, instruction-boundary DMA resume cost.
use super::*;
use crate::{
dma::{DMA_BASE, DMA_STRIDE},
input::Buttons,
io::{HALTCNT, IE, IME, KEYCNT, TIMER_BASE, WAITCNT},
machine::{Machine, StepKind},
memory::{BIOS_SIZE, ROM_START},
};
const RAM: u32 = 0x0200_1000;
const DEST: u32 = 0x0300_1000;
const PC: u32 = ROM_START + 0x100;
fn program(thumb: bool, pc: u32, instruction: u32) -> (Cpu, Memory) {
let nop = if thumb { 0x46c0_46c0_u32 } else { 0xe1a0_0000 };
let offset = (pc & 0x01ff_ffff) as usize;
let mut rom = nop.to_le_bytes().repeat((offset + 32) / 4);
let width = if thumb { 2 } else { 4 };
rom[offset..offset + width].copy_from_slice(&instruction.to_le_bytes()[..width]);
let mut cpu = Cpu::new(pc);
if thumb {
cpu.instruction_set = InstructionSet::Thumb;
}
cpu.registers[1] = RAM;
(cpu, Memory::new(rom).unwrap())
}
fn configure(bus: &mut Memory, channel: usize, source: u32, destination: u32, count: u16) {
let base = DMA_BASE + channel as u32 * DMA_STRIDE;
bus.write16(base + 10, 0).unwrap();
bus.write32(base, source).unwrap();
bus.write32(base + 4, destination).unwrap();
bus.write32(base + 8, 0x8400_0000 | u32::from(count))
.unwrap();
}
fn dma(bus: &mut Memory) {
configure(bus, 3, RAM, DEST, 1);
assert_eq!(bus.step_dma().unwrap().unwrap().0, 3);
}
#[test]
fn first_post_dma_code_access_uses_n_then_s_for_all_rom_wait_settings() {
for thumb in [false, true] {
for window in 0..3 {
for first in 0..4 {
for second in 0..2 {
for prefetch in [0, 0x4000] {
let pc = PC + window as u32 * 0x0200_0000;
let instruction = if thumb { 0x46c0 } else { 0xe1a0_0000 };
let (mut cpu, mut bus) = program(thumb, pc, instruction);
let waitcnt = (first << [2, 5, 8][window])
| (second << [4, 7, 10][window])
| prefetch;
bus.write16(WAITCNT, waitcnt).unwrap();
dma(&mut bus);
// Independent table, not the production timing helper.
let n = [5, 4, 3, 9][first as usize];
let s = if second == 1 { 2 } else { [3, 5, 9][window] };
assert_eq!(
cpu.step_timed(&mut bus).unwrap().code_cycles,
n + if thumb { 0 } else { s }
);
assert_eq!(
cpu.step_timed(&mut bus).unwrap().code_cycles,
if thumb { s } else { 2 * s }
);
assert_eq!(bus.cycles(), 9); // CPU-only timed calls do not advance devices.
}
}
}
}
}
}
#[test]
fn ram_and_rom_dma_sources_both_break_the_cpu_sequence() {
for source in [RAM, DEST, ROM_START] {
let (cpu, mut bus) = program(false, PC, 0xe1a0_0000);
configure(&mut bus, 3, source, DEST + 16, 1);
let mut machine = Machine::new(cpu, bus);
assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 3 });
assert_eq!(machine.step().unwrap(), StepKind::Instruction);
assert_eq!(machine.last_timing().code_cycles, 8);
machine.step().unwrap();
assert_eq!(machine.last_timing().code_cycles, 6);
}
}
#[test]
fn register_configuration_alone_is_not_a_dma_resume_but_blocked_source_completion_is() {
let (mut cpu, mut bus) = program(false, PC, 0xe1a0_0000);
configure(&mut bus, 3, RAM, DEST, 1);
// CPU-only execution does not service the configured transfer.
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 6);
bus.step_dma().unwrap().unwrap();
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 8);
configure(&mut bus, 3, 0, DEST, 1); // Known channel data, no BIOS read.
bus.step_dma().unwrap().unwrap();
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 8);
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 6);
}
#[test]
fn multiple_units_and_channel_preemption_produce_one_resume_not_a_penalty_queue() {
let (cpu, mut bus) = program(false, PC, 0xe1a0_0000);
configure(&mut bus, 3, RAM, DEST, 2);
let mut machine = Machine::new(cpu, bus);
machine.step().unwrap();
configure(machine.memory_mut(), 0, RAM + 16, DEST + 16, 1);
for channel in [0, 3] {
assert_eq!(machine.step().unwrap(), StepKind::Dma { channel });
}
machine.step().unwrap();
assert_eq!(machine.last_timing().code_cycles, 8);
machine.step().unwrap();
assert_eq!(machine.last_timing().code_cycles, 6);
// Another block after CPU progress must mark the next resume again.
configure(machine.memory_mut(), 3, RAM, DEST, 1);
machine.step().unwrap();
machine.step().unwrap();
assert_eq!(machine.last_timing().code_cycles, 8);
}
#[test]
fn failed_dma_preserves_an_existing_resume_but_cannot_create_one() {
for completed in [false, true] {
let (mut cpu, mut bus) = program(false, PC, 0xe1a0_0000);
if completed {
dma(&mut bus);
}
configure(&mut bus, 3, 0x0e00_0000, DEST, 1);
let cycles = bus.cycles();
for _ in 0..2 {
assert!(bus.step_dma().is_err());
assert_eq!(bus.cycles(), cycles);
}
bus.write16(DMA_BASE + 3 * DMA_STRIDE + 10, 0).unwrap();
assert_eq!(
cpu.step_timed(&mut bus).unwrap().code_cycles,
if completed { 8 } else { 6 }
);
}
}
#[test]
fn failed_cpu_fetch_and_data_access_preserve_resume_until_success() {
let (mut cpu, mut bus) = program(false, PC, 0xe591_0000); // LDR r0,[r1].
dma(&mut bus);
cpu.registers[15] = 0x0e00_0000;
assert!(cpu.step_timed(&mut bus).is_err());
bus.write32(DEST + 0x100, 0xffff_ffff).unwrap(); // Unsupported ARM encoding.
cpu.registers[15] = DEST + 0x100;
assert!(cpu.step_timed(&mut bus).is_err());
cpu.registers[15] = PC;
cpu.registers[1] = 0x0e00_0000;
let before = cpu.clone();
for _ in 0..2 {
assert!(cpu.step_timed(&mut bus).is_err());
assert_eq!(cpu, before);
}
cpu.registers[1] = RAM;
let timing = cpu.step_timed(&mut bus).unwrap();
assert_eq!(timing.code_cycles, 8);
assert_eq!(timing.data_cycles, 6);
assert_eq!(timing.internal_cycles, 1);
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 6);
}
#[test]
fn host_accesses_and_clock_advances_do_not_consume_resume_but_untimed_cpu_does() {
for untimed in [false, true] {
let (mut cpu, mut bus) = program(false, PC, 0xe1a0_0000);
dma(&mut bus);
bus.read32(PC).unwrap();
bus.read32(RAM).unwrap();
bus.write32(DEST, 0).unwrap();
bus.advance_cycles(10);
if untimed {
cpu.step(&mut bus).unwrap();
}
assert_eq!(
cpu.step_timed(&mut bus).unwrap().code_cycles,
if untimed { 6 } else { 8 }
);
}
}
#[test]
fn skipped_conditions_stores_and_boundaries_consume_resume_without_double_charging() {
for (pc, instruction, data) in [
(PC, 0x0591_0000, 0), // LDREQ skipped: Z is clear.
(PC, 0xe581_0000, 6), // STR already has an N code access.
(ROM_START + 0x20000, 0xe1a0_0000, 0), // Boundary already forces N.
] {
let (mut cpu, mut bus) = program(false, pc, instruction);
dma(&mut bus);
let timing = cpu.step_timed(&mut bus).unwrap();
assert_eq!(timing.code_cycles, 8);
assert_eq!(timing.data_cycles, data);
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 6);
}
}
#[test]
fn waitcnt_cpu_store_uses_old_code_cost_but_dma_store_applies_before_resume() {
let (mut cpu, mut bus) = program(false, PC, 0xe581_0000);
cpu.registers[0] = 0x18;
cpu.registers[1] = WAITCNT;
dma(&mut bus);
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 8);
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 4);
let (mut cpu, mut bus) = program(false, PC, 0xe1a0_0000);
bus.write32(RAM, 0x18).unwrap();
configure(&mut bus, 3, RAM, WAITCNT, 1);
bus.step_dma().unwrap().unwrap();
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 5); // N=3, S=2.
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 4);
}
#[test]
fn ram_instruction_consumes_resume_instead_of_delaying_it_until_rom_execution() {
let (mut cpu, mut bus) = program(false, PC, 0xe1a0_0000);
bus.write32(DEST + 0x100, 0xe1a0_0000).unwrap();
cpu.registers[15] = DEST + 0x100;
dma(&mut bus);
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 1);
cpu.registers[15] = PC;
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 6);
}
#[test]
fn branch_refill_and_irq_entry_consume_resume_without_an_extra_nominal_access() {
let (mut cpu, mut bus) = program(false, PC, 0xea00_0000); // B PC+8.
dma(&mut bus);
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 20); // Existing N+2S summary.
assert_eq!(cpu.step_timed(&mut bus).unwrap().code_cycles, 6);
let (cpu, _) = program(false, PC, 0xe1a0_0000);
let mut bios = vec![0; BIOS_SIZE];
bios[0x18..0x1c].copy_from_slice(&0xe1a0_0000_u32.to_le_bytes());
let mut bus = Memory::with_bios(0xe1a0_0000_u32.to_le_bytes().repeat(80), bios).unwrap();
configure(&mut bus, 3, RAM, DEST, 1);
bus.write16(DMA_BASE + 3 * DMA_STRIDE + 10, 0xc400).unwrap();
bus.write16(IE, 1 << 11).unwrap();
bus.write16(IME, 1).unwrap();
let mut machine = Machine::new(cpu, bus);
assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 3 });
assert_eq!(machine.step().unwrap(), StepKind::IrqEntry);
assert_eq!(machine.last_timing().code_cycles, 3);
// Inspect the bus through a separate CPU after IRQ entry, before a handler runs.
let mut probe = Cpu::new(PC);
assert_eq!(
probe.step_timed(machine.memory_mut()).unwrap().code_cycles,
6
);
assert_eq!(machine.memory().cycles(), 12);
}
#[test]
fn halt_and_stop_idle_do_not_consume_a_completed_dma_resume() {
for stop in [false, true] {
let (cpu, mut bus) = program(false, PC, 0xe1a0_0000);
dma(&mut bus);
let mut machine = Machine::new(cpu, bus);
if stop {
machine.memory_mut().write16(KEYCNT, 0x4001).unwrap();
machine.memory_mut().write16(IE, 1 << 12).unwrap();
machine.memory_mut().write8(HALTCNT, 0x80).unwrap();
assert_eq!(machine.step().unwrap(), StepKind::StopIdle);
machine.memory_mut().set_buttons(Buttons::from_bits(1));
} else {
machine.memory_mut().write16(IE, 8).unwrap();
machine
.memory_mut()
.write32(TIMER_BASE, 0x00c0_fff0)
.unwrap();
machine.memory_mut().write8(HALTCNT, 0).unwrap();
assert_eq!(machine.step().unwrap(), StepKind::HaltIdle);
}
assert_eq!(machine.step().unwrap(), StepKind::Instruction);
assert_eq!(machine.last_timing().code_cycles, 8);
machine.step().unwrap();
assert_eq!(machine.last_timing().code_cycles, 6);
}
}
#[test]
fn resumed_n_cost_advances_devices_and_defers_irq_to_the_following_step() {
let (cpu, mut bus) = program(false, PC, 0xe1a0_0000);
dma(&mut bus);
bus.write32(TIMER_BASE, 0x00c0_fff9).unwrap(); // Overflow after seven cycles.
bus.write16(IE, 8).unwrap();
bus.write16(IME, 1).unwrap();
let mut machine = Machine::new(cpu, bus);
let start = machine.cycles();
assert_eq!(machine.step().unwrap(), StepKind::Instruction);
assert_eq!(machine.cycles() - start, 8);
assert_eq!(machine.step().unwrap(), StepKind::IrqEntry);
}

View file

@ -59,6 +59,7 @@ impl Cpu {
let width = self.instruction_set.access_width();
// A WAITCNT store affects subsequent instructions, not this code access.
let waitcnt = memory.waitcnt();
let code_kind = memory.cpu_code_kind(summary.code_kind);
memory.begin_data_timing();
let result = self.execute_fetched(instruction, memory);
let data_cycles = memory.end_data_timing();
@ -66,7 +67,7 @@ impl Cpu {
let code_cycles = if summary.refill {
refill_cycles(waitcnt, self.pc(), self.instruction_set.access_width())
} else {
bus_cycles(waitcnt, pc, width, summary.code_kind)
bus_cycles(waitcnt, pc, width, code_kind)
};
Ok(StepTiming {
code_cycles,

View file

@ -81,6 +81,8 @@ pub struct Memory {
io: Io,
cycles: u64,
data_timing: Cell<Option<DataTiming>>,
// A completed DMA unit breaks the CPU's next nominal code-access sequence.
cpu_resume_nonsequential: bool,
cpu_access: Option<CpuAccess>,
bios_prefetch: Option<u32>,
iwram_bus: IwramBus,
@ -104,6 +106,7 @@ impl Memory {
io: Io::default(),
cycles: 0,
data_timing: Cell::new(None),
cpu_resume_nonsequential: false,
cpu_access: None,
bios_prefetch: None,
iwram_bus: IwramBus::default(),
@ -141,6 +144,8 @@ impl Memory {
/// Finish the instruction/IRQ entry that started while running. A STOP store
/// still pays its full nominal cost before later steps freeze the system.
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);
}
@ -341,6 +346,7 @@ impl Memory {
// queue a second block while the current block is still transferring.
self.advance_cycles(timing.total());
self.io.complete_dma_unit(channel, data_latch);
self.cpu_resume_nonsequential = true;
Ok(Some((channel, timing)))
}
@ -413,6 +419,10 @@ impl Memory {
}
pub(crate) fn end_cpu_access(&mut self, succeeded: bool, sequential: bool) {
if succeeded {
// Timed and untimed CPU execution both consume a resume. Errors do not.
self.cpu_resume_nonsequential = false;
}
self.iwram_bus.finish(succeeded, sequential);
if let Some(access) = self.cpu_access.take() {
if succeeded && access.pc < BIOS_SIZE as u32 {
@ -486,6 +496,16 @@ impl Memory {
self.io.waitcnt()
}
/// Resolve the next nominal code access without consuming pending DMA history.
/// Successful instruction/IRQ completion consumes it; host reads and idle do not.
pub(crate) fn cpu_code_kind(&self, kind: AccessKind) -> AccessKind {
if self.cpu_resume_nonsequential {
AccessKind::NonSequential
} else {
kind
}
}
pub(crate) fn begin_data_timing(&self) {
debug_assert!(self.data_timing.get().is_none());
self.data_timing.set(Some(DataTiming::default()));