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)] #[cfg(test)]
mod compare_psr_tests; mod compare_psr_tests;
#[cfg(test)] #[cfg(test)]
mod dma_resume_tests;
#[cfg(test)]
mod exception_tests; mod exception_tests;
#[cfg(test)] #[cfg(test)]
mod instruction_tests; 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(); let width = self.instruction_set.access_width();
// A WAITCNT store affects subsequent instructions, not this code access. // A WAITCNT store affects subsequent instructions, not this code access.
let waitcnt = memory.waitcnt(); let waitcnt = memory.waitcnt();
let code_kind = memory.cpu_code_kind(summary.code_kind);
memory.begin_data_timing(); memory.begin_data_timing();
let result = self.execute_fetched(instruction, memory); let result = self.execute_fetched(instruction, memory);
let data_cycles = memory.end_data_timing(); let data_cycles = memory.end_data_timing();
@ -66,7 +67,7 @@ impl Cpu {
let code_cycles = if summary.refill { let code_cycles = if summary.refill {
refill_cycles(waitcnt, self.pc(), self.instruction_set.access_width()) refill_cycles(waitcnt, self.pc(), self.instruction_set.access_width())
} else { } else {
bus_cycles(waitcnt, pc, width, summary.code_kind) bus_cycles(waitcnt, pc, width, code_kind)
}; };
Ok(StepTiming { Ok(StepTiming {
code_cycles, code_cycles,

View file

@ -81,6 +81,8 @@ pub struct Memory {
io: Io, io: Io,
cycles: u64, cycles: u64,
data_timing: Cell<Option<DataTiming>>, 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>, cpu_access: Option<CpuAccess>,
bios_prefetch: Option<u32>, bios_prefetch: Option<u32>,
iwram_bus: IwramBus, iwram_bus: IwramBus,
@ -104,6 +106,7 @@ impl Memory {
io: Io::default(), io: Io::default(),
cycles: 0, cycles: 0,
data_timing: Cell::new(None), data_timing: Cell::new(None),
cpu_resume_nonsequential: false,
cpu_access: None, cpu_access: None,
bios_prefetch: None, bios_prefetch: None,
iwram_bus: IwramBus::default(), iwram_bus: IwramBus::default(),
@ -141,6 +144,8 @@ impl Memory {
/// Finish the instruction/IRQ entry that started while running. A STOP store /// Finish the instruction/IRQ entry that started while running. A STOP store
/// still pays its full nominal cost before later steps freeze the system. /// still pays its full nominal cost before later steps freeze the system.
pub(crate) fn complete_cpu_step(&mut self, cycles: u32) { 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.advance_running_cycles(cycles);
} }
@ -341,6 +346,7 @@ impl Memory {
// queue a second block while the current block is still transferring. // queue a second block while the current block is still transferring.
self.advance_cycles(timing.total()); self.advance_cycles(timing.total());
self.io.complete_dma_unit(channel, data_latch); self.io.complete_dma_unit(channel, data_latch);
self.cpu_resume_nonsequential = true;
Ok(Some((channel, timing))) Ok(Some((channel, timing)))
} }
@ -413,6 +419,10 @@ impl Memory {
} }
pub(crate) fn end_cpu_access(&mut self, succeeded: bool, sequential: bool) { 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); self.iwram_bus.finish(succeeded, sequential);
if let Some(access) = self.cpu_access.take() { if let Some(access) = self.cpu_access.take() {
if succeeded && access.pc < BIOS_SIZE as u32 { if succeeded && access.pc < BIOS_SIZE as u32 {
@ -486,6 +496,16 @@ impl Memory {
self.io.waitcnt() 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) { pub(crate) fn begin_data_timing(&self) {
debug_assert!(self.data_timing.get().is_none()); debug_assert!(self.data_timing.get().is_none());
self.data_timing.set(Some(DataTiming::default())); self.data_timing.set(Some(DataTiming::default()));

View file

@ -47,7 +47,8 @@ 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, and timing breakdowns | | `crates/gba-core/src/timing.rs` | Bus widths, wait-state costs, and timing breakdowns |
| `crates/gba-core/src/cpu/timing.rs` | ARM/Thumb cycle summaries and data-access accounting | | `crates/gba-core/src/cpu/timing.rs` | ARM/Thumb cycle summaries, nominal DMA resume, and data-access accounting |
| `crates/gba-core/src/cpu/dma_resume_tests.rs` | One-shot non-sequential DMA resume costs, wait settings, failures, idle, IRQs, and API isolation |
| `crates/gba-core/src/cpu/timing_tests.rs` | Instruction timing and semantic-equivalence checks | | `crates/gba-core/src/cpu/timing_tests.rs` | Instruction timing and semantic-equivalence checks |
| `crates/gba-demos/src/timer_demo.rs` | Original timer-configuration program and IRQ handler | | `crates/gba-demos/src/timer_demo.rs` | Original timer-configuration program and IRQ handler |
| `Cargo.toml` | Workspace members and shared package settings | | `Cargo.toml` | Workspace members and shared package settings |

View file

@ -288,6 +288,24 @@ The writable mask is `0x5fff`; the Game Pak type flag reads as GBA, and the uppe
A CPU write to WAITCNT does not retroactively change that instruction's code-access cost. A CPU write to WAITCNT does not retroactively change that instruction's code-access cost.
The new settings apply to subsequent code accesses. The new settings apply to subsequent code accesses.
### Nominal CPU resume after DMA
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.
A non-refill instruction uses its normal width and current WAITCNT with N instead of S.
ARM word accesses retain an S cost for their second halfword.
Stores and ROM boundaries that already use N do not receive an additional cost.
Successful timed or untimed CPU execution consumes pending resume, including skipped conditions and internal-memory instructions.
Successful machine IRQ entry also consumes it. Failed CPU/DMA steps preserve it.
Host access, clock-only advancement, and HALT/STOP idle do not consume it.
A failed DMA unit cannot create pending resume.
DMA WAITCNT writes apply before resume; CPU WAITCNT writes still use the previous code-access settings.
Branch and exception refill summaries retain their existing destination-based `1N+2S` costs, without an added resume access.
The old-PC fetch is not modeled separately. This remains nominal timing, not a per-access pipeline.
See [the evidence and limitations](../research/dma-resume-timing.md).
Important timing limits: Important timing limits:
- Code S/N counts follow instruction summaries, not a simulated fetch pipeline. - Code S/N counts follow instruction summaries, not a simulated fetch pipeline.

View file

@ -47,7 +47,10 @@ 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. 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. 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.
Startup scheduling delays, channel-resumption costs, CPU fetch-state effects, and display-bus contention remain unmodeled. 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.
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.
Special timing modes (sound FIFO/video capture), Game Pak DRQ, and prohibited source mode 3 return `DmaError::UnsupportedControl`. Special timing modes (sound FIFO/video capture), Game Pak DRQ, and prohibited source mode 3 return `DmaError::UnsupportedControl`.
DMA reads below work RAM reuse known channel data as described below. They never expose BIOS bytes. DMA reads below work RAM reuse known channel data as described below. They never expose BIOS bytes.

View file

@ -139,6 +139,14 @@ DMA IWRAM continuation research:
See [the bounded DMA continuation extension](research/iwram-bus-history.md#dma-continuation-extension). See [the bounded DMA continuation extension](research/iwram-bus-history.md#dma-continuation-extension).
These source reviews do not establish sub-instruction arbitration, exact CPU resume timing, or hardware conformance. These source reviews do not establish sub-instruction arbitration, exact CPU resume timing, or hardware conformance.
DMA resume-timing research:
- [NanoBoyAdvance force-nseq-access test at cc3f4a28](https://codeberg.org/nba-emu/hw-test/src/commit/cc3f4a286cdef823980d9b353bd70befc9927d28/dma/force-nseq-access/source/main.c), for equal timer expectations after RAM-source and ROM-source DMA sequences.
- The pinned NanoBoyAdvance bus and ares DMA/bus implementations above explicitly break sequential CPU access after DMA ownership.
See [non-sequential CPU resume](research/dma-resume-timing.md) for the nominal subset and unmodeled refill/prefetch details.
The public test was reviewed, not executed. No hardware timing pass 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.
The ignore file excludes `roms/`, common game ROM extensions, and save files. The ignore file excludes `roms/`, common game ROM extensions, and save files.

View file

@ -0,0 +1,74 @@
# Non-sequential CPU resume after DMA
## Finding and evidence
DMA can break the CPU's sequential bus access even when the transfer uses only work RAM.
The prior instruction timing path always used its instruction summary, without retaining DMA ownership changes.
An ordinary ROM instruction after DMA therefore still received a sequential code-access cost.
Sources reviewed:
- [NanoBoyAdvance force-nseq-access test at cc3f4a28](https://codeberg.org/nba-emu/hw-test/src/commit/cc3f4a286cdef823980d9b353bd70befc9927d28/dma/force-nseq-access/source/main.c) compares EWRAM-source and ROM-source transfers during a short instruction sequence.
Both tests specify the same timer result. This is a whole-sequence expectation, not an isolated first-fetch measurement.
- [NanoBoyAdvance bus at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/bus/bus.cc) forces non-sequential ROM access when the previous access was DMA and the current access is CPU.
The condition does not require the DMA source or destination to be ROM.
- [ares DMA at 6f6786e0](https://github.com/ares-emulator/ares/blob/6f6786e04f0822a3475463df284f313ab8518d51/ares/gba/cpu/dma.cpp) marks ARM7 accesses non-sequential when DMA takes bus ownership.
Its [bus dispatch](https://github.com/ares-emulator/ares/blob/6f6786e04f0822a3475463df284f313ab8518d51/ares/gba/cpu/bus.cpp) uses that state for CPU burst selection.
These sources support a sequence break. They do not establish exact timing for our instruction-boundary scheduler.
The public test source was reviewed outside the repository. Its ROM was not imported, built, or executed here.
No physical-hardware measurement or external-emulator differential run was performed.
## Bounded implementation
A successful DMA unit sets a pending non-sequential CPU resume in `Memory`.
Further units and channel changes keep that single pending state. They do not queue additional costs.
A failed unit cannot create or consume it. A blocked-source unit that successfully writes known channel data sets it normally.
Register configuration, host reads/writes, and clock-only advancement do not consume it.
Before instruction execution, `Cpu::step_timed` captures the pending state and WAITCNT.
For a non-refill instruction, pending resume changes the nominal code-access kind to non-sequential.
The existing bus-cost function applies the code width and ROM-window wait settings.
An ARM word still uses a non-sequential first halfword and a sequential second halfword.
This is a change of access kind, not a fixed positive surcharge: some wait settings make N cheaper than S.
Successful CPU execution consumes the pending state in timed and untimed APIs.
Skipped conditions consume it too. Failed fetches, unsupported instructions, and failed data accesses preserve it for retry.
Execution in internal memory consumes it even when N and S costs are equal.
The state does not wait for a later ROM instruction.
Machine IRQ entry consumes it through successful CPU-step completion.
HALT/STOP idle does not consume it. Normal device progress, wake-up, and IRQ sampling rules still apply.
A CPU WAITCNT store uses the pre-instruction settings for its code access.
A completed DMA WAITCNT store changes the settings before CPU resume.
## Limits
The model has no persistent fetch pipeline or per-access DMA arbitration.
It charges the current instruction address, not a separately scheduled PC+4/PC+8 pipeline fetch.
DMA still executes only between instructions, not between data accesses or during CPU internal cycles.
Existing refill summaries remain destination-based `1N+2S` costs.
A resumed branch or exception consumes pending state without adding another nominal access.
This prevents double-counting the existing N component, but does not model the discarded old-PC fetch separately.
Stores and 128 KiB ROM boundaries already use N and receive no duplicate cost.
Game Pak prefetch remains unimplemented; WAITCNT bit 14 does not alter this nominal timing path.
This change does not select a DMA value for CPU open-bus reads.
DMA channel data, bounded IWRAM lane history, and protected BIOS history remain separate.
General bus-data ownership and exact resume timing still need independent per-access validation.
## Original regression coverage
Tests cover ARM/Thumb widths, all three ROM windows, every N/S wait setting, and stored prefetch-bit values.
They check RAM/ROM DMA sources, repeated units, channel preemption, blocked-source completion, and configuration-only isolation.
Other checks cover CPU/DMA errors, mixed timed/untimed APIs, host isolation, WAITCNT writes, skipped conditions, stores, and ROM boundaries.
Refills, IRQ entry, internal-memory execution, HALT/STOP idle, timer advancement, and subsequent IRQ delivery have explicit checks.
The new resume cases failed on the prior sequential-only path before implementation.
## Validation result
Workspace and core/demo tests pass in debug and release on Darwin arm64.
Formatting, lint checks, rustdoc, preparation tests, native ROM windows, and graphics smoke modes pass.
Public ARM, Thumb, memory, and BIOS reports match their previous passing results exactly.
These suites are regression checks, not independent conformance tests for DMA resume timing.

View file

@ -37,6 +37,7 @@ Update this file when a feature lands or a limit is removed.
- Four direct memory access (DMA) channels, with immediate, VBlank, and visible-line HBlank triggers. - Four direct memory access (DMA) channels, with immediate, VBlank, and visible-line HBlank triggers.
- Halfword/word DMA, address controls, repeat, channel priority, CPU pausing, and completion IRQs. - Halfword/word DMA, address controls, repeat, channel priority, CPU pausing, and completion IRQs.
- Separate retained DMA channel data, with known-value reuse for blocked sources below work RAM. - Separate retained DMA channel data, with known-value reuse for blocked sources below work RAM.
- One-shot non-sequential nominal CPU code timing after successful DMA, including RAM-only transfers.
- HALT with enabled-request wake-up, continued device clocks, and bounded idle steps. - HALT with enabled-request wake-up, continued device clocks, and bounded idle steps.
- BIOS-only CPU writes to POSTFLG/HALTCNT, with HALT and keypad-wake STOP. - BIOS-only CPU writes to POSTFLG/HALTCNT, with HALT and keypad-wake STOP.
- STOP freezes CPU/DMA/device clocks, retains device phases, and reports a stopped frame runner without spinning. - STOP freezes CPU/DMA/device clocks, retains device phases, and reports a stopped frame runner without spinning.
@ -150,7 +151,7 @@ Writes to cartridge addresses return errors instead of modeling cartridge hardwa
## Next steps ## Next steps
1. Extend bounded IWRAM DMA continuations toward general bus ownership with independent per-access resume tests. 1. Extend bounded IWRAM DMA history and nominal non-sequential resume timing with independent per-access bus tests.
General DMA open-bus reads and sub-instruction arbitration remain separate from retained channel data and local IWRAM lanes. General DMA open-bus reads and sub-instruction arbitration remain separate from retained channel data and local IWRAM lanes.
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.

View file

@ -210,6 +210,9 @@ Original channel-data tests cover word retention, halfword duplication, blocked-
They check cold/zero history, cancel/re-enable, source-counter crossings, repeats, priority, failed-unit rollback, and BIOS snapshot isolation. They check cold/zero history, cancel/re-enable, source-counter crossings, repeats, priority, failed-unit rollback, and BIOS snapshot isolation.
These tests do not establish general DMA open-bus reads or DMA-to-CPU handoff. See [the research scope](research/dma-data-latches.md). These tests do not establish general DMA open-bus reads or DMA-to-CPU handoff. See [the research scope](research/dma-data-latches.md).
Tests verify channel priority/preemption, CPU pausing, display/timer progress, IRQ acknowledgement/return, and WAITCNT costs. Tests verify channel priority/preemption, CPU pausing, display/timer progress, IRQ acknowledgement/return, and WAITCNT costs.
CPU resume tests cover ARM/Thumb widths, all ROM-window wait settings, one-shot N timing, and RAM-only DMA.
They check errors, host isolation, mixed timed/untimed APIs, WAITCNT updates, refills, IRQ entry, HALT/STOP, and timer delivery.
These verify the [nominal resume subset](research/dma-resume-timing.md), not a full fetch pipeline or physical-hardware timing.
Diagnostic tests check failed-unit isolation, retained earlier transfers, unsupported modes, and CPU block-store atomicity. Diagnostic tests check failed-unit isolation, retained earlier transfers, unsupported modes, and CPU block-store atomicity.
Graphics startup tests verify that DMA3, not CPU stores or host writes, fills every background pixel. Graphics startup tests verify that DMA3, not CPU stores or host writes, fills every background pixel.
Display tests cover exact scanline boundaries, line-227 VBlank behavior, all 228 HBlank events, and read-only status fields. Display tests cover exact scanline boundaries, line-227 VBlank behavior, all 228 HBlank events, and read-only status fields.