feat(dma): retain per-channel transfer data

Retain independent known data across DMA blocks. Duplicate mapped halfword sources and select the destination lane for blocked sources below work RAM. Keep cold history diagnostic, commit only successful units, and preserve separate CPU/BIOS history rules.

Add 13 original regressions and pinned source research. CPU resume ownership, general DMA open bus, and IWRAM DMA lane effects remain explicit limits.

Validated on Darwin arm64: 930 workspace and 861 core/demo tests in debug and release, 39 Python tests, native ROM windows, ten window smoke modes, fmt, clippy, and rustdoc. Public ARM/Thumb/memory/BIOS reports match the previous results exactly.
This commit is contained in:
0xrsydn 2026-10-06 02:14:19 +07:00
commit e72c643640
12 changed files with 465 additions and 24 deletions

View file

@ -1,6 +1,7 @@
//! Four-channel GBA direct memory access (DMA) controller. //! Four-channel GBA direct memory access (DMA) controller.
//! Transfers are scheduled one data unit at a time. Startup/resumption delays, //! Transfers are scheduled one data unit at a time. Startup/resumption delays,
//! open-bus latches, sound FIFO, video capture, and Game Pak DRQ are not modeled. //! CPU bus handoff, sound FIFO, video capture, and Game Pak DRQ are not modeled.
//! Each channel retains known source data across blocks, including blocked reads.
use std::{error::Error, fmt}; use std::{error::Error, fmt};
@ -65,6 +66,8 @@ struct Channel {
remaining: u32, remaining: u32,
active: bool, active: bool,
first: bool, first: bool,
// Unknown at synthetic startup. Enable/disable does not reset retained data.
data_latch: Option<u32>,
} }
impl Channel { impl Channel {
@ -111,6 +114,7 @@ pub(crate) struct Transfer {
pub destination: u32, pub destination: u32,
pub width: AccessWidth, pub width: AccessWidth,
pub first: bool, pub first: bool,
pub data_latch: Option<u32>,
} }
impl Dma { impl Dma {
@ -199,6 +203,7 @@ impl Dma {
destination: channel.current_destination, destination: channel.current_destination,
width: channel.width(), width: channel.width(),
first: channel.first, first: channel.first,
data_latch: channel.data_latch,
})); }));
} }
} }
@ -206,8 +211,9 @@ impl Dma {
} }
/// Commit one successful data unit after its bus cycles. Return completion IRQ bits. /// Commit one successful data unit after its bus cycles. Return completion IRQ bits.
pub(crate) fn complete_unit(&mut self, index: usize) -> u16 { pub(crate) fn complete_unit(&mut self, index: usize, data_latch: u32) -> u16 {
let channel = &mut self.channels[index]; let channel = &mut self.channels[index];
channel.data_latch = Some(data_latch);
let width = channel.width().bytes(); let width = channel.width().bytes();
// The Game Pak source counter increments regardless of source control. // The Game Pak source counter increments regardless of source control.
let source_mode = if (0x08..=0x0d).contains(&(channel.current_source >> 24)) { let source_mode = if (0x08..=0x0d).contains(&(channel.current_source >> 24)) {

View file

@ -383,8 +383,8 @@ impl Io {
self.dma.next() self.dma.next()
} }
pub(crate) fn complete_dma_unit(&mut self, channel: usize) { pub(crate) fn complete_dma_unit(&mut self, channel: usize, data_latch: u32) {
self.pending |= self.dma.complete_unit(channel); self.pending |= self.dma.complete_unit(channel, data_latch);
self.wake_if_requested(); self.wake_if_requested();
} }

View file

@ -67,7 +67,8 @@ struct CpuAccess {
/// Protected BIOS reads separately retain the snapshot from BIOS execution. /// Protected BIOS reads separately retain the snapshot from BIOS execution.
/// Sequential Thumb IWRAM accesses retain addressed bus lanes transactionally. /// Sequential Thumb IWRAM accesses retain addressed bus lanes transactionally.
/// Successful refills into Thumb IWRAM sample the target pair to establish history. /// Successful refills into Thumb IWRAM sample the target pair to establish history.
/// A full fetch pipeline, ARM-target refill history, and DMA bus latches are not modeled. /// DMA channels retain transfer data separately; CPU bus handoff remains unmodeled.
/// A full fetch pipeline and ARM-target refill history are not modeled.
pub struct Memory { pub struct Memory {
external_ram: Vec<u8>, external_ram: Vec<u8>,
internal_ram: Vec<u8>, internal_ram: Vec<u8>,
@ -268,7 +269,7 @@ impl Memory {
address: transfer.destination, address: transfer.destination,
}); });
} }
if transfer.source < 0x0200_0000 { if transfer.source < 0x0200_0000 && transfer.data_latch.is_none() {
return Err(DmaError::UnsupportedSource { return Err(DmaError::UnsupportedSource {
channel, channel,
address: transfer.source, address: transfer.source,
@ -281,18 +282,33 @@ impl Memory {
}); });
} }
let map_error = |error| DmaError::Memory { channel, error }; let map_error = |error| DmaError::Memory { channel, error };
// Reads have no side effects. A failed destination leaves the unit pending. // Validate before reading or committing channel data. Failed units are atomic.
match transfer.width {
AccessWidth::Halfword => self.write_index::<2>(transfer.destination),
AccessWidth::Word => self.write_index::<4>(transfer.destination),
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 { let value = match transfer.width {
AccessWidth::Halfword => { AccessWidth::Halfword => data_latch >> ((transfer.destination & 2) * 8),
self.write_index::<2>(transfer.destination) AccessWidth::Word => data_latch,
.map_err(map_error)?;
u32::from(self.read16(transfer.source).map_err(map_error)?)
}
AccessWidth::Word => {
self.write_index::<4>(transfer.destination)
.map_err(map_error)?;
self.read32(transfer.source).map_err(map_error)?
}
AccessWidth::Byte => unreachable!("DMA is never byte-wide"), AccessWidth::Byte => unreachable!("DMA is never byte-wide"),
}; };
let kind = if transfer.first { let kind = if transfer.first {
@ -318,7 +334,7 @@ impl Memory {
// Keep this channel active during its cycles: another edge must not // Keep this channel active during its cycles: another edge must not
// 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); self.io.complete_dma_unit(channel, data_latch);
Ok(Some((channel, timing))) Ok(Some((channel, timing)))
} }

View file

@ -8,6 +8,9 @@ use gba_core::{
timing::{bus_cycles, AccessKind, AccessWidth}, timing::{bus_cycles, AccessKind, AccessWidth},
}; };
#[path = "dma/latch.rs"]
mod latch;
const SOURCE: u32 = 0x0200_0000; const SOURCE: u32 = 0x0200_0000;
const DEST: u32 = 0x0300_0000; const DEST: u32 = 0x0300_0000;

View file

@ -0,0 +1,297 @@
//! Original tests of per-channel retained data, not CPU open-bus handoff.
use super::*;
const DATA: u32 = 0x9a73_b5e1;
const OTHER: u32 = 0x2468_1357;
fn seed(machine: &mut Machine, channel: usize, value: u32) {
machine.memory_mut().write32(SOURCE, value).unwrap();
configure(machine.memory_mut(), channel, SOURCE, DEST, 1, 0x400);
units(machine, channel, 1);
}
fn blocked(machine: &mut Machine, channel: usize, destination: u32, control: u16) {
configure(machine.memory_mut(), channel, 0, destination, 1, control);
units(machine, channel, 1);
}
#[test]
fn blocked_sources_reuse_each_channels_word_without_reading_bios() {
for channel in 0..4 {
for source in [0, 0x3ffc, 0x4000, 0x0100_0000, 0x01ff_fffc] {
let mut bios = vec![0x62; BIOS_SIZE];
bios[..4].copy_from_slice(&OTHER.to_le_bytes());
let mut machine = Machine::new(
Cpu::new(ROM_START),
Memory::with_bios(words(&[0xeaff_fffe]), bios).unwrap(),
);
seed(&mut machine, channel, DATA);
machine.memory_mut().write32(SOURCE, 0).unwrap();
assert_eq!(machine.memory().read32(0).unwrap(), OTHER);
configure(machine.memory_mut(), channel, source, DEST + 4, 1, 0x400);
units(&mut machine, channel, 1);
assert_eq!(machine.memory().read32(DEST + 4).unwrap(), DATA);
assert!(!enabled(&machine, channel));
}
}
}
#[test]
fn blocked_halfwords_select_destination_lane_and_do_not_duplicate_retained_word() {
for channel in 0..4 {
let mut machine = machine();
seed(&mut machine, channel, DATA);
// Neither source alignment nor halfword width may replace the old full word.
for source in [0, 2, 0x3ffe, 0x01ff_fffe] {
for lane in [0, 2] {
configure(machine.memory_mut(), channel, source, DEST + 4 + lane, 1, 0);
units(&mut machine, channel, 1);
}
assert_eq!(machine.memory().read32(DEST + 4).unwrap(), DATA);
}
blocked(&mut machine, channel, DEST + 8, 0x400);
assert_eq!(machine.memory().read32(DEST + 8).unwrap(), DATA);
}
}
#[test]
fn mapped_halfword_sources_duplicate_either_source_lane_in_the_channel_latch() {
for channel in 0..4 {
for source_lane in [0, 2] {
for destination_lane in [0, 2] {
let mut machine = machine();
seed(&mut machine, channel, OTHER);
machine.memory_mut().write32(SOURCE, DATA).unwrap();
configure(
machine.memory_mut(),
channel,
SOURCE + source_lane,
DEST + destination_lane,
1,
0,
);
units(&mut machine, channel, 1);
let half = (DATA >> (source_lane * 8)) as u16;
assert_eq!(
machine.memory().read16(DEST + destination_lane).unwrap(),
half
);
blocked(&mut machine, channel, DEST + 4, 0x400);
assert_eq!(
machine.memory().read32(DEST + 4).unwrap(),
u32::from(half) * 0x10001
);
}
}
}
}
#[test]
fn latches_are_independent_and_survive_completion_disable_and_host_access() {
let mut machine = machine();
for channel in 0..4 {
seed(&mut machine, channel, DATA ^ (0x1111_1111 * channel as u32));
}
machine.memory_mut().write32(SOURCE, OTHER).unwrap();
assert_eq!(machine.memory().read32(SOURCE).unwrap(), OTHER);
for channel in (0..4).rev() {
machine.memory_mut().write16(base(channel) + 10, 0).unwrap();
blocked(&mut machine, channel, DEST + channel as u32 * 4, 0x400);
assert_eq!(
machine.memory().read32(DEST + channel as u32 * 4).unwrap(),
DATA ^ (0x1111_1111 * channel as u32)
);
}
}
#[test]
fn cold_channel_stays_unknown_despite_other_channels_and_host_reads() {
for channel in 0..4 {
let mut machine = machine();
seed(&mut machine, (channel + 1) % 4, DATA);
assert_eq!(machine.memory().read32(DEST).unwrap(), DATA);
configure(machine.memory_mut(), channel, 0, DEST + 4, 1, 0x4400);
let cycles = machine.cycles();
let timing = machine.last_timing();
let cpu = machine.cpu().clone();
for _ in 0..2 {
assert_eq!(
machine.step(),
Err(MachineError::Dma(DmaError::UnsupportedSource {
channel,
address: 0
}))
);
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.cycles(), cycles);
assert_eq!(machine.last_timing(), timing);
assert_eq!(machine.memory().read32(DEST + 4).unwrap(), 0);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
assert!(enabled(&machine, channel));
}
}
}
#[test]
fn zero_is_known_data_not_an_unknown_latch() {
let mut machine = machine();
seed(&mut machine, 3, 0);
machine.memory_mut().write32(DEST + 4, u32::MAX).unwrap();
blocked(&mut machine, 3, DEST + 4, 0x400);
assert_eq!(machine.memory().read32(DEST + 4).unwrap(), 0);
}
#[test]
fn failed_units_preserve_the_old_latch_without_new_data_or_irq() {
for (source, destination, control) in [
(0x0e00_0000, DEST, 0x4400), // Unmapped reads do not use a known latch.
(SOURCE, ROM_START, 0x4400),
(SOURCE, 0x0400_0058, 0x4400),
(SOURCE, DMA_BASE, 0x4400),
(SOURCE, 0x0400_0300, 0x4400),
(SOURCE, DEST, 0x4580), // Unsupported source mode.
(0, ROM_START, 0x4400), // A blocked source cannot bypass destination checks.
] {
let mut machine = machine();
seed(&mut machine, 3, DATA);
machine.memory_mut().write32(SOURCE, OTHER).unwrap();
configure(machine.memory_mut(), 3, source, destination, 1, control);
let cycles = machine.cycles();
let timing = machine.last_timing();
for _ in 0..2 {
assert!(machine.step().is_err());
assert_eq!(machine.cycles(), cycles);
assert_eq!(machine.last_timing(), timing);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
assert_eq!(machine.memory().read32(DEST).unwrap(), DATA);
}
machine.memory_mut().write16(base(3) + 10, 0).unwrap();
blocked(&mut machine, 3, DEST + 4, 0x400);
assert_eq!(machine.memory().read32(DEST + 4).unwrap(), DATA);
}
}
#[test]
fn failed_destination_does_not_seed_a_cold_channel() {
let mut machine = machine();
machine.memory_mut().write32(SOURCE, DATA).unwrap();
configure(machine.memory_mut(), 3, SOURCE, ROM_START, 1, 0x400);
assert!(machine.step().is_err());
machine.memory_mut().write16(base(3) + 10, 0).unwrap();
configure(machine.memory_mut(), 3, 0, DEST, 1, 0x400);
assert_eq!(
machine.step(),
Err(MachineError::Dma(DmaError::UnsupportedSource {
channel: 3,
address: 0
}))
);
assert_eq!(machine.cycles(), 0);
}
#[test]
fn source_counter_crossing_into_blocked_region_retains_the_last_completed_unit() {
let mut machine = machine();
machine.memory_mut().write32(SOURCE, DATA).unwrap();
configure(machine.memory_mut(), 3, SOURCE, DEST, 3, 0x4480); // Decrement word source, IRQ.
units(&mut machine, 3, 3);
for offset in [0, 4, 8] {
assert_eq!(machine.memory().read32(DEST + offset).unwrap(), DATA);
}
assert_eq!(machine.cycles(), 13); // 6+1+2, then two 1+1 units.
assert_eq!(machine.memory().read16(IF).unwrap(), 1 << 11);
assert!(!enabled(&machine, 3));
}
#[test]
fn failed_later_read_retains_last_successful_halfword_not_partial_bytes() {
let mut machine = Machine::new(
Cpu::new(ROM_START),
Memory::new(vec![0x37, 0x92, 0x6a]).unwrap(),
);
configure(machine.memory_mut(), 3, ROM_START, DEST, 2, 0);
units(&mut machine, 3, 1);
assert!(machine.step().is_err());
machine.memory_mut().write16(base(3) + 10, 0).unwrap();
blocked(&mut machine, 3, DEST + 4, 0x400);
assert_eq!(machine.memory().read32(DEST + 4).unwrap(), 0x9237_9237);
}
#[test]
fn cancellation_keeps_the_last_completed_unit_not_programmed_source_bytes() {
let mut machine = machine();
machine.memory_mut().write32(SOURCE, DATA).unwrap();
machine.memory_mut().write32(SOURCE + 4, OTHER).unwrap();
configure(machine.memory_mut(), 3, SOURCE, DEST, 3, 0x400);
units(&mut machine, 3, 2);
assert!(enabled(&machine, 3));
machine.memory_mut().write16(base(3) + 10, 0).unwrap();
machine.memory_mut().write32(SOURCE + 4, 0).unwrap();
blocked(&mut machine, 3, DEST + 8, 0x400);
assert_eq!(machine.memory().read32(DEST + 8).unwrap(), OTHER);
}
#[test]
fn blocked_dma_does_not_read_or_replace_the_protected_cpu_bios_word() {
let mut bios = vec![0; BIOS_SIZE];
bios[..16].copy_from_slice(&words(&[
0xe59f_0000, // LDR r0,[pc]: ROM destination.
0xe12f_ff10, // BX r0: retained BIOS word comes from address 12.
ROM_START,
OTHER,
]));
let mut machine = Machine::new(
Cpu::new(0),
Memory::with_bios(words(&[0xe592_1000, 0xeaff_fffe, 0]), bios).unwrap(),
); // ROM LDR r1,[r2], with r2=0.
for _ in 0..2 {
assert_eq!(machine.step().unwrap(), StepKind::Instruction);
}
seed(&mut machine, 3, DATA);
blocked(&mut machine, 3, DEST + 4, 0x400);
assert_eq!(machine.memory().read32(DEST + 4).unwrap(), DATA);
assert_eq!(machine.memory().read32(0).unwrap(), 0xe59f_0000);
assert_eq!(machine.step().unwrap(), StepKind::Instruction);
assert_eq!(machine.cpu().registers()[1], OTHER);
}
#[test]
fn repeated_hblank_blocks_and_priority_keep_channel_history_and_nominal_clocks() {
let mut machine = machine();
seed(&mut machine, 0, OTHER);
seed(&mut machine, 3, DATA);
machine
.memory_mut()
.write32(TIMER_BASE, 0x0080_0000)
.unwrap();
let start = machine.cycles();
for channel in [3, 0] {
configure(
machine.memory_mut(),
channel,
0,
DEST + 8 + channel as u32 * 8,
1,
0x6340,
);
} // Halfword, fixed source, increment/reload destination, HBlank repeat, IRQ.
for cycle in [
u64::from(HBLANK_START),
u64::from(HBLANK_START + CYCLES_PER_LINE),
] {
advance_to(&mut machine, cycle);
for channel in [0, 3] {
units(&mut machine, channel, 1);
assert_eq!(machine.last_timing().data_cycles, 2);
assert_eq!(machine.last_timing().internal_cycles, 2);
assert!(enabled(&machine, channel));
}
assert_eq!(machine.memory().read16(DEST + 8).unwrap(), OTHER as u16);
assert_eq!(machine.memory().read16(DEST + 32).unwrap(), DATA as u16);
assert_eq!(machine.memory().read16(IF).unwrap() & 0x900, 0x900);
}
assert_eq!(
u64::from(machine.memory().read16(TIMER_BASE).unwrap()),
machine.cycles() - start
);
}

View file

@ -43,7 +43,7 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/src/memory.rs` | Memory mapping, I/O routing, write validation, and device clock | | `crates/gba-core/src/memory.rs` | Memory mapping, I/O routing, write validation, and device clock |
| `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.rs` | I/O registers, timers, HALT/STOP wake-up, next-event bounds, and IRQ latches |
| `crates/gba-core/src/display.rs` | Display clock, scanline status, comparison edges, and display IRQ events | | `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, 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 and data-access accounting |
@ -126,6 +126,7 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/tests/sprites/affine.rs` | Affine matrices, drawing areas, signed sampling, shared groups, and floating-point reference tests | | `crates/gba-core/tests/sprites/affine.rs` | Affine matrices, drawing areas, signed sampling, shared groups, and floating-point reference tests |
| `crates/gba-core/tests/display.rs` | Display boundaries, register masks, IRQ handlers, and VBlank frame execution | | `crates/gba-core/tests/display.rs` | Display boundaries, register masks, IRQ handlers, and VBlank frame execution |
| `crates/gba-core/tests/dma.rs` | DMA widths, latches, priority, triggers, IRQs, timing, errors, and CPU integration | | `crates/gba-core/tests/dma.rs` | DMA widths, latches, priority, triggers, IRQs, timing, errors, and CPU integration |
| `crates/gba-core/tests/dma/latch.rs` | Retained channel data, blocked sources, lane selection, isolation, errors, repeats, and BIOS protection |
| `crates/gba-core/tests/stop.rs` | STOP clock gating, live keypad wake, retained DMA/device phases, frame-runner results, and access rules | | `crates/gba-core/tests/stop.rs` | STOP clock gating, live keypad wake, retained DMA/device phases, frame-runner results, and access rules |
| `crates/gba-core/tests/bios/stop.rs` | ARM/Thumb Stop calls, caller preservation, keypad wake, and stopped frame running | | `crates/gba-core/tests/bios/stop.rs` | ARM/Thumb Stop calls, caller preservation, keypad wake, and stopped frame running |
| `crates/gba-core/tests/halt.rs` | HALT wake masks, idle timing, BIOS-only power writes, DMA progress, and validation | | `crates/gba-core/tests/halt.rs` | HALT wake masks, idle timing, BIOS-only power writes, DMA progress, and validation |

View file

@ -99,7 +99,8 @@ Limits remain explicit:
- Thumb open bus uses the supported region rules below, including bounded sequential internal-RAM lane history. - Thumb open bus uses the supported region rules below, including bounded sequential internal-RAM lane history.
- [BIOS-protected reads](bios.md#cpu-bios-read-protection) use separate retained ARM PC+8 or aligned Thumb PC+4 word snapshots. - [BIOS-protected reads](bios.md#cpu-bios-read-protection) use separate retained ARM PC+8 or aligned Thumb PC+4 word snapshots.
- Unused/write-only I/O reads, DMA latches, and disabled-RAM reads are not modeled here. - Unused/write-only I/O reads, DMA-to-CPU bus handoff, and disabled-RAM reads are not modeled here.
[DMA channel data](dma.md#retained-channel-data) is separate and never overrides a CPU snapshot.
- Missing BIOS, truncated ROM, unsupported I/O, and save-memory accesses retain their existing diagnostics. - Missing BIOS, truncated ROM, unsupported I/O, and save-memory accesses retain their existing diagnostics.
- Unused-memory writes remain diagnostics rather than ignored hardware writes. Swaps cannot silently discard their write. - Unused-memory writes remain diagnostics rather than ignored hardware writes. Swaps cannot silently discard their write.
- There is no persistent instruction pipeline. Thumb IWRAM refills sample their target pair, but DMA-to-CPU transitions remain unmodeled. - There is no persistent instruction pipeline. Thumb IWRAM refills sample their target pair, but DMA-to-CPU transitions remain unmodeled.

View file

@ -50,10 +50,38 @@ Each step reports zero code cycles. Overlapping transfers read each unit after t
Startup scheduling delays, channel-resumption costs, CPU fetch-state effects, and display-bus contention remain unmodeled. Startup scheduling delays, channel-resumption costs, CPU fetch-state effects, 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, including BIOS reads, return `DmaError::UnsupportedSource` rather than exposing BIOS bytes. DMA reads below work RAM reuse known channel data as described below. They never expose BIOS bytes.
DMA writes to DMA registers return `DmaError::RegisterDestination`; self-modifying transfers are not supported. DMA writes to DMA registers return `DmaError::RegisterDestination`; self-modifying transfers are not supported.
Other accesses use the existing memory map, alignment checks, mirrors, and I/O write rules. Other accesses use the existing memory map, alignment checks, mirrors, and I/O write rules.
Unmapped memory and cartridge writes remain diagnostics, not hardware open-bus/latch behavior. Other unmapped reads and cartridge writes remain diagnostics, not general DMA open-bus behavior.
A failed unit changes no memory, device state, or clock. Earlier units remain committed; the failed unit remains pending. A failed unit changes no memory, device state, or clock. Earlier units remain committed; the failed unit remains pending.
Successful units invalidate bounded sequential Thumb IWRAM history because DMA-to-CPU latch ordering is not modeled. Successful units invalidate bounded sequential Thumb IWRAM history because DMA-to-CPU latch ordering is not modeled.
Failed units preserve that history. No DMA value is substituted for a CPU open-bus read. Failed units preserve that history. No DMA value is substituted for a CPU open-bus read.
## Retained channel data
Each channel has a separate retained 32-bit data value, distinct from its programmed addresses and count.
A successful mapped word transfer replaces the full value.
A successful mapped halfword transfer duplicates the source halfword into both lanes of the retained word.
Normal source alignment and address masks apply before the read.
A source below `0x02000000` cannot read the bus, including when a BIOS image is present.
With known channel data, a word transfer writes the retained word.
A halfword transfer writes its low half at a word-aligned destination, or its high half when destination bit 1 is set.
The blocked read does not change either retained lane. Source bit 1 does not choose the written lane.
This also applies when a source counter crosses into the blocked region during a block.
Channel data survives completion, disable/cancel, re-enable, and repeated blanking transfers.
Other channels, CPU reads, and host inspection/setup do not change it.
Synthetic startup leaves each channel unknown, not zero.
A blocked source with unknown channel data returns `DmaError::UnsupportedSource` and leaves the unit pending.
A transferred zero is known data and can be reused.
The emulator commits retained data only after a complete successful unit, under its existing atomic diagnostic policy.
Failed source reads, unsupported controls, and failed destinations preserve the previous value and clocks.
Earlier successful units remain committed. Normal destination validation, nominal timing, and completion IRQ rules still apply.
This error policy is not a model of hardware data aborts.
See [the latch evidence and scope](../research/dma-data-latches.md).
General DMA open-bus reads, IWRAM lane effects, and CPU bus ownership on resume remain unimplemented.
In particular, the known channel value does not supply unused/write-only I/O reads or CPU open-bus snapshots.

View file

@ -121,6 +121,15 @@ IWRAM refill research:
These sources support bounded refill sampling, not a claim of cycle-accurate pipeline or DMA behavior. These sources support bounded refill sampling, not a claim of cycle-accurate pipeline or DMA behavior.
DMA data-latch research:
- [NanoBoyAdvance hardware latch test at cc3f4a28](https://codeberg.org/nba-emu/hw-test/src/commit/cc3f4a286cdef823980d9b353bd70befc9927d28/dma/latch/source/main.c), for per-channel ownership, halfword duplication, blocked-source retention, and destination lane selection.
Its [BSD-3-Clause license](https://codeberg.org/nba-emu/hw-test/src/commit/cc3f4a286cdef823980d9b353bd70befc9927d28/LICENSE) was reviewed. No source or ROM was imported.
- [NanoBoyAdvance DMA at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/dma/dma.cc) and [its data structures](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/dma/dma.hh), compared with its bus read ordering.
- [ares DMA at 6f6786e0](https://github.com/ares-emulator/ares/blob/6f6786e04f0822a3475463df284f313ab8518d51/ares/gba/cpu/dma.cpp), compared with its IWRAM and general-bus dispatch above.
See [DMA data-latch findings](research/dma-data-latches.md). Source review is not a local hardware-test pass.
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 @@
# DMA retained data and CPU bus ownership
## Finding
DMA channel data, IWRAM lane history, and the CPU's general bus value are different state.
A channel's retained data cannot safely replace every open-bus read after a transfer.
The previous implementation rejected all DMA sources below work RAM, even after the channel had transferred known data.
That diagnostic omitted a documented retained-data case.
The [NanoBoyAdvance hardware latch test](https://codeberg.org/nba-emu/hw-test/src/commit/cc3f4a286cdef823980d9b353bd70befc9927d28/dma/latch/source/main.c) specifies these expectations:
- Each DMA channel retains its own 32-bit value.
- A mapped word source replaces that value.
- A mapped halfword source duplicates its value in both retained halfwords.
- A blocked BIOS source does not replace the retained value.
- A halfword write from a blocked source selects the retained lane using destination bit 1.
- A write-only I/O source can read ordinary bus history, not simply the previous channel value.
The test explicitly exercises source zero. Its comments extend the blocked-source rule below `0x02000000`.
[NanoBoyAdvance's DMA implementation](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/dma/dma.cc) and [ares DMA](https://github.com/ares-emulator/ares/blob/6f6786e04f0822a3475463df284f313ab8518d51/ares/gba/cpu/dma.cpp) use that boundary.
Both retain channel data across transfers and duplicate successful halfword reads.
These sources support the implemented subset, but do not establish cold hardware latch contents.
## Why CPU resume remains separate
[NanoBoyAdvance's bus](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/bus/bus.cc) tracks the last access type.
Its open-bus path can select DMA data when DMA was the preceding access.
DMA runs within CPU bus calls, so that condition depends on which CPU access resumes.
It does not imply that every first instruction after DMA must return a channel value.
[ares bus dispatch](https://github.com/ares-emulator/ares/blob/6f6786e04f0822a3475463df284f313ab8518d51/ares/gba/cpu/bus.cpp) routes DMA accesses through the relevant memory buses.
Its IWRAM bus and general data register are separate, and pending DMA runs before CPU accesses.
GBATEK's possible DMA influence on IWRAM is not sufficient to choose CPU resume ordering here.
Our instruction-boundary scheduler does not yet represent these per-access transitions.
Therefore, this change keeps successful-DMA invalidation of bounded Thumb IWRAM history.
Failed DMA preserves that history. No channel value is injected into CPU or BIOS snapshots.
The next CPU-bus work needs independent resume-ordering coverage rather than a universal last-transfer override.
## Implemented subset
Each `dma::Channel` holds `Option<u32>` retained data. `None` means synthetic startup has not established a value.
A pending `Transfer` includes that channel's value, but selection does not change it.
`Memory::step_dma` validates the destination and obtains either mapped source data or known blocked-source data.
For mapped halfword sources, both retained lanes receive the source halfword.
For blocked sources, the retained word stays unchanged and the destination selects the written halfword.
No BIOS image read occurs in the blocked path.
After a successful write and nominal device progress, completion commits the retained word with the unit's existing address/count changes.
Enable, disable, cancellation, completion, and repeat do not clear retained data.
Each channel remains independent. Host operations and CPU reads never seed channel data.
Unknown blocked sources retain `DmaError::UnsupportedSource`; known zero is valid data.
Other unmapped sources and unsupported controls remain diagnostics even with known channel data.
The existing atomic diagnostic policy commits no new latch value for a failed unit.
Earlier successful units remain visible. A partial failed read cannot replace their retained value.
Nominal source/destination costs, CPU pausing, trigger scheduling, and completion IRQs are unchanged.
General DMA open-bus reads, write-only register readback, local IWRAM lane effects, and CPU resume ownership remain outside this subset.
## Validation scope
Original regressions cover both widths, all channels, source/destination halfword lanes, and supplied or absent BIOS images.
They check independent ownership, zero/unknown values, host isolation, source-counter crossings, cancellation, repeats, priority, and failed-unit retention.
A CPU instruction sequence also checks that blocked DMA leaves the separately retained BIOS word unchanged.
The new retained-data cases failed on the previous unconditional blocked-source diagnostic.
External source and license files were reviewed outside the repository.
No external test ROM or source was imported. The hardware latch ROM was not built or run here.
These are original regression checks, not a local physical-hardware or external-emulator differential result.
Workspace and core/demo tests pass on Darwin arm64 in debug and release builds.
Formatting, lint checks, rustdoc, preparation tests, native ROM windows, and all graphics smoke modes pass.
Public ARM, Thumb, memory, and BIOS reports match the previous passing reports exactly.
Those suites are regression checks, not independent validation of retained DMA data or CPU bus handoff.

View file

@ -36,6 +36,7 @@ Update this file when a feature lands or a limit is removed.
- A 228-line display clock, VCOUNT/DISPSTAT, and VBlank/HBlank/VCount-match interrupts. - A 228-line display clock, VCOUNT/DISPSTAT, and VBlank/HBlank/VCount-match interrupts.
- 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.
- 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.
@ -137,7 +138,8 @@ Thumb reads use [region-dependent snapshots](hardware/cpu.md#thumb-unused-memory
Cold or invalidated IWRAM history remains diagnostic until all lanes are known. Cold or invalidated IWRAM history remains diagnostic until all lanes are known.
Refills into Thumb IWRAM establish new target-pair history after the instruction succeeds. Refills into Thumb IWRAM establish new target-pair history after the instruction succeeds.
Accepted machine IRQs, successful DMA, unsupported refills, and other execution states/regions invalidate continuation history. Accepted machine IRQs, successful DMA, unsupported refills, and other execution states/regions invalidate continuation history.
This is not a fetch pipeline. ARM-target refill history, region-crossing fetches, DMA latches, and unused/write-only I/O open bus remain unsupported. This is not a fetch pipeline. ARM-target refill history, region-crossing fetches, DMA-to-CPU handoff, and unused/write-only I/O open bus remain unsupported.
[DMA channel data latches](hardware/dma.md#retained-channel-data) support blocked-source reuse, but never replace CPU bus history.
BIOS protection uses separate retained BIOS fetch snapshots as described in [BIOS behavior](hardware/bios.md#cpu-bios-read-protection). BIOS protection uses separate retained BIOS fetch snapshots as described in [BIOS behavior](hardware/bios.md#cpu-bios-read-protection).
Host inspection and instruction fetches remain strict. Other unmapped reads return errors. Host inspection and instruction fetches remain strict. Other unmapped reads return errors.
Reads beyond the supplied cartridge bytes also return errors. Reads beyond the supplied cartridge bytes also return errors.
@ -147,7 +149,8 @@ Writes to cartridge addresses return errors instead of modeling cartridge hardwa
## Next steps ## Next steps
1. Research and validate DMA-to-CPU bus history, including latch ownership and IWRAM effects. 1. Extend the DMA channel-latch foundation with validated DMA-to-CPU bus ownership and IWRAM effects.
General DMA open-bus reads need separate evidence; blocked-source reuse does not establish their values.
Keep unknown history diagnostic until fetch ordering and latch ownership have independent coverage. Keep unknown history diagnostic until fetch ordering and latch ownership have independent coverage.
Validate BIOS snapshots with independent hardware tests before claiming exact refill or data-access history. Validate BIOS snapshots with independent hardware tests before claiming exact refill or data-access history.
The desktop runner still uses only the original BIOS. Keep protected reads image-derived for all supplied images. The desktop runner still uses only the original BIOS. Keep protected reads image-derived for all supplied images.

View file

@ -204,6 +204,9 @@ They distinguish wake-up from IF latching and test stale requests, interrupt mas
ARM/Thumb BIOS Stop tests verify caller preservation. Frame-runner tests check prompt stopped results and unchanged output images. ARM/Thumb BIOS Stop tests verify caller preservation. Frame-runner tests check prompt stopped results and unchanged output images.
The graphics test verifies BIOS VBlank waiting, one IRQ callback per update, and status restoration before each redraw. The graphics test verifies BIOS VBlank waiting, one IRQ callback per update, and status restoration before each redraw.
DMA tests cover both widths, all supported address modes, count limits, register masks, enable latches, and repeated transfers. DMA tests cover both widths, all supported address modes, count limits, register masks, enable latches, and repeated transfers.
Original channel-data tests cover word retention, halfword duplication, blocked-source destination lanes, and channel 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).
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.
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.