feat(machine): schedule display DMA IRQs and HALT

Integrate nominal bus timing, device clocks, display events, DMA transfers, keypad state, and interrupt delivery.
This commit is contained in:
0xrsydn 2026-10-05 12:55:35 +07:00
commit bb6784615a
10 changed files with 3172 additions and 0 deletions

132
src/display.rs Normal file
View file

@ -0,0 +1,132 @@
//! GBA display clock and status, independent of pixel rendering.
//! Events use supplied clock cycles. Device updates occur at instruction, DMA,
//! or HALT-idle boundaries; display bus contention and IRQ synchronization delays
//! are not modeled. Forced blank does not stop this clock.
pub const CPU_HZ: u32 = 16_777_216;
pub const CYCLES_PER_LINE: u32 = 1232;
pub const HBLANK_START: u32 = 1006;
pub const VISIBLE_LINES: u32 = 160;
pub const LINES_PER_FRAME: u32 = 228;
pub const CYCLES_PER_FRAME: u32 = CYCLES_PER_LINE * LINES_PER_FRAME;
pub const VBLANK_START: u32 = CYCLES_PER_LINE * VISIBLE_LINES;
/// Display position after the latest clock advance. Event counters wrap at u64::MAX.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DisplayPosition {
pub scanline: u16,
pub line_cycle: u16,
/// Number of completed 228-line frames (increments on entry to line zero).
pub frames: u64,
/// Number of entries to line 160, independent of local interrupt enables.
pub vblanks: u64,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct Display {
phase: u32,
control: u16,
frames: u64,
vblanks: u64,
}
impl Display {
pub(crate) fn position(&self) -> DisplayPosition {
DisplayPosition {
scanline: (self.phase / CYCLES_PER_LINE) as u16,
line_cycle: (self.phase % CYCLES_PER_LINE) as u16,
frames: self.frames,
vblanks: self.vblanks,
}
}
pub(crate) fn status(&self) -> u16 {
let position = self.position();
// Line 227 is in the non-visible region, but the VBlank flag is clear.
let vblank = (160..227).contains(&position.scanline);
let hblank = u32::from(position.line_cycle) >= HBLANK_START;
let matched = position.scanline == self.control >> 8;
self.control | u16::from(vblank) | (u16::from(hblank) << 1) | (u16::from(matched) << 2)
}
/// Write a DISPSTAT byte. Status bits and unused GBA bits are read-only.
/// Changing the comparator can generate a rising match event immediately.
/// Enabling an IRQ while its status is already set does not synthesize an edge.
pub(crate) fn write_status(&mut self, high: bool, value: u8) -> u16 {
let previously_matched = self.status() & 4 != 0;
let shift = u32::from(high) * 8;
self.control = ((self.control & !(0xff << shift)) | (u16::from(value) << shift)) & 0xff38;
if !previously_matched && self.status() & 4 != 0 && self.control & 0x20 != 0 {
4
} else {
0
}
}
/// Next HBlank entry or scanline start. Both are strictly in the future.
pub(crate) fn next_event_cycles(&self) -> u32 {
let cycle = self.phase % CYCLES_PER_LINE;
if cycle < HBLANK_START {
HBLANK_START - cycle
} else {
CYCLES_PER_LINE - cycle
}
}
/// Display DMA requests are independent of DISPSTAT interrupt enables.
/// Unlike HBlank IRQs, HBlank DMA requests occur only on visible lines.
pub(crate) fn dma_events(&self, cycles: u32) -> (bool, bool) {
let vblank = crossings(self.phase, cycles, CYCLES_PER_FRAME, VBLANK_START) != 0;
let visible_hblanks = |time: u64| {
let frame = u64::from(CYCLES_PER_FRAME);
let line = u64::from(CYCLES_PER_LINE);
let visible = u64::from(VISIBLE_LINES);
time / frame * visible
+ ((time % frame + line - u64::from(HBLANK_START)) / line).min(visible)
};
let begin = u64::from(self.phase);
let hblank = visible_hblanks(begin + u64::from(cycles)) != visible_hblanks(begin);
(vblank, hblank)
}
/// Return IRQ request bits crossed during the interval (old time, new time].
/// Periodic arithmetic handles large batches without skipping events or
/// looping per cycle/scanline. Status derives from the final clock position.
pub(crate) fn advance(&mut self, cycles: u32) -> u16 {
let vblanks = crossings(self.phase, cycles, CYCLES_PER_FRAME, VBLANK_START);
let frames = crossings(self.phase, cycles, CYCLES_PER_FRAME, 0);
let mut requests = 0;
if self.control & 8 != 0 && vblanks != 0 {
requests |= 1;
}
// HBlank status and IRQ events occur on all 228 lines, including VBlank.
if self.control & 0x10 != 0
&& crossings(self.phase, cycles, CYCLES_PER_LINE, HBLANK_START) != 0
{
requests |= 2;
}
let compare = u32::from(self.control >> 8);
if self.control & 0x20 != 0
&& compare < LINES_PER_FRAME
&& crossings(
self.phase,
cycles,
CYCLES_PER_FRAME,
compare * CYCLES_PER_LINE,
) != 0
{
requests |= 4;
}
self.phase =
((u64::from(self.phase) + u64::from(cycles)) % u64::from(CYCLES_PER_FRAME)) as u32;
self.vblanks = self.vblanks.wrapping_add(vblanks);
self.frames = self.frames.wrapping_add(frames);
requests
}
}
fn crossings(start: u32, elapsed: u32, period: u32, offset: u32) -> u64 {
let shift = u64::from(period - offset);
let begin = u64::from(start) + shift;
(begin + u64::from(elapsed)) / u64::from(period) - begin / u64::from(period)
}

259
src/dma.rs Normal file
View file

@ -0,0 +1,259 @@
//! Four-channel GBA direct memory access (DMA) controller.
//! 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.
use std::{error::Error, fmt};
use crate::{memory::MemoryError, timing::AccessWidth};
pub const DMA_BASE: u32 = 0x0400_00b0;
pub const DMA_STRIDE: u32 = 12;
pub(crate) const DMA_END: u32 = DMA_BASE + 4 * DMA_STRIDE - 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DmaError {
Memory { channel: usize, error: MemoryError },
UnsupportedControl { channel: usize, control: u16 },
UnsupportedSource { channel: usize, address: u32 },
RegisterDestination { channel: usize, address: u32 },
PowerControlDestination { channel: usize, address: u32 },
}
impl fmt::Display for DmaError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Memory { channel, error } => write!(f, "DMA{channel}: {error}"),
Self::UnsupportedControl { channel, control } => {
write!(f, "DMA{channel}: unsupported control {control:#06x}")
}
Self::UnsupportedSource { channel, address } => {
write!(f, "DMA{channel}: unsupported source {address:#010x}")
}
Self::PowerControlDestination { channel, address } => {
write!(
f,
"DMA{channel}: power-control writes are not supported ({address:#010x})"
)
}
Self::RegisterDestination { channel, address } => {
write!(
f,
"DMA{channel}: writes to DMA registers are not supported ({address:#010x})"
)
}
}
}
}
impl Error for DmaError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Memory { error, .. } => Some(error),
_ => None,
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct Channel {
source: u32,
destination: u32,
count: u16,
control: u16,
current_source: u32,
current_destination: u32,
remaining: u32,
active: bool,
first: bool,
}
impl Channel {
fn enabled(&self) -> bool {
self.control & 0x8000 != 0
}
fn start_timing(&self) -> u16 {
(self.control >> 12) & 3
}
fn unsupported(&self) -> bool {
self.start_timing() == 3 || self.control & 0x800 != 0 || self.control & 0x180 == 0x180
}
fn width(&self) -> AccessWidth {
if self.control & 0x400 != 0 {
AccessWidth::Word
} else {
AccessWidth::Halfword
}
}
fn initial_count(&self, index: usize) -> u32 {
if self.count != 0 {
u32::from(self.count)
} else if index == 3 {
0x1_0000
} else {
0x4000
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct Dma {
channels: [Channel; 4],
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Transfer {
pub channel: usize,
pub source: u32,
pub destination: u32,
pub width: AccessWidth,
pub first: bool,
}
impl Dma {
/// Address/count registers are write-only. Zero is a diagnostic placeholder,
/// not hardware open-bus behavior. Only CNT_H has meaningful readback.
pub(crate) fn read8(&self, address: u32) -> u8 {
let offset = address - DMA_BASE;
let channel = &self.channels[(offset / DMA_STRIDE) as usize];
match offset % DMA_STRIDE {
10 => channel.control as u8,
11 => (channel.control >> 8) as u8,
_ => 0,
}
}
pub(crate) fn write8(&mut self, address: u32, value: u8) {
let offset = address - DMA_BASE;
let index = (offset / DMA_STRIDE) as usize;
let channel = &mut self.channels[index];
let register_byte = offset % DMA_STRIDE;
match register_byte {
0..=3 => {
channel.source = replace_byte(channel.source, register_byte, value)
& if index == 0 { 0x07ff_fffe } else { 0x0fff_fffe };
}
4..=7 => {
channel.destination = replace_byte(channel.destination, register_byte - 4, value)
& if index == 3 { 0x0fff_fffe } else { 0x07ff_fffe };
}
8..=9 => {
channel.count = replace_byte(u32::from(channel.count), register_byte - 8, value)
as u16
& if index == 3 { 0xffff } else { 0x3fff };
}
10..=11 => {
let was_enabled = channel.enabled();
channel.control =
replace_byte(u32::from(channel.control), register_byte - 10, value) as u16
& if index == 3 { 0xffe0 } else { 0xf7e0 };
if !channel.enabled() {
channel.active = false;
} else if !was_enabled {
let mask = !(channel.width().bytes() - 1);
channel.current_source = channel.source & mask;
channel.current_destination = channel.destination & mask;
channel.remaining = channel.initial_count(index);
channel.first = true;
channel.active = channel.start_timing() == 0;
}
}
_ => unreachable!("DMA register byte"),
}
}
/// Blanking requests are edges, not queued jobs. An active channel ignores
/// further edges until its current block completes.
pub(crate) fn trigger(&mut self, vblank: bool, hblank: bool) {
for channel in &mut self.channels {
if channel.enabled()
&& !channel.active
&& ((vblank && channel.start_timing() == 1)
|| (hblank && channel.start_timing() == 2))
{
channel.active = true;
}
}
}
/// Select the highest-priority ready channel without changing its state.
/// Unsupported enabled modes report a diagnostic rather than silently waiting.
pub(crate) fn next(&self) -> Result<Option<Transfer>, DmaError> {
for (index, channel) in self.channels.iter().enumerate() {
if !channel.enabled() {
continue;
}
if channel.unsupported() {
return Err(DmaError::UnsupportedControl {
channel: index,
control: channel.control,
});
}
if channel.active {
return Ok(Some(Transfer {
channel: index,
source: channel.current_source,
destination: channel.current_destination,
width: channel.width(),
first: channel.first,
}));
}
}
Ok(None)
}
/// Commit one successful data unit after its bus cycles. Return completion IRQ bits.
pub(crate) fn complete_unit(&mut self, index: usize) -> u16 {
let channel = &mut self.channels[index];
let width = channel.width().bytes();
// The Game Pak source counter increments regardless of source control.
let source_mode = if (0x08..=0x0d).contains(&(channel.current_source >> 24)) {
0
} else {
(channel.control >> 7) & 3
};
channel.current_source = advance_address(channel.current_source, width, source_mode);
channel.current_destination = advance_address(
channel.current_destination,
width,
(channel.control >> 5) & 3,
);
channel.remaining -= 1;
channel.first = false;
if channel.remaining != 0 {
return 0;
}
channel.active = false;
if channel.control & 0x200 != 0 && channel.start_timing() != 0 {
channel.remaining = channel.initial_count(index);
channel.first = true;
if channel.control & 0x60 == 0x60 {
channel.current_destination = channel.destination & !(width - 1);
}
} else {
channel.control &= !0x8000;
}
if channel.control & 0x4000 != 0 {
1 << (8 + index)
} else {
0
}
}
}
fn advance_address(address: u32, width: u32, mode: u16) -> u32 {
match mode {
0 | 3 => address.wrapping_add(width),
1 => address.wrapping_sub(width),
2 => address,
_ => unreachable!("two-bit address control"),
}
}
fn replace_byte(previous: u32, byte: u32, value: u8) -> u32 {
let shift = byte * 8;
(previous & !(0xff << shift)) | (u32::from(value) << shift)
}

42
src/input.rs Normal file
View file

@ -0,0 +1,42 @@
//! Platform-independent GBA button state. Bits are pressed-high here;
//! the KEYINPUT register exposes their active-low hardware representation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u16)]
pub enum Button {
A = 1 << 0,
B = 1 << 1,
Select = 1 << 2,
Start = 1 << 3,
Right = 1 << 4,
Left = 1 << 5,
Up = 1 << 6,
Down = 1 << 7,
R = 1 << 8,
L = 1 << 9,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Buttons(u16);
impl Buttons {
pub fn from_bits(bits: u16) -> Self {
Self(bits & 0x03ff)
}
pub fn bits(self) -> u16 {
self.0
}
pub fn with(self, button: Button, pressed: bool) -> Self {
Self(if pressed {
self.0 | button as u16
} else {
self.0 & !(button as u16)
})
}
pub(crate) fn keyinput(self) -> u16 {
!self.0 & 0x03ff
}
}

176
src/machine.rs Normal file
View file

@ -0,0 +1,176 @@
//! CPU/device integration with nominal ARM7 instruction and GBA bus costs.
//! Device clocks update at instruction, DMA-unit, or HALT-idle boundaries, not per bus access.
use std::{error::Error, fmt};
use crate::{
cpu::{Cpu, CpuError},
dma::DmaError,
memory::Memory,
timing::StepTiming,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepKind {
Instruction,
IrqEntry,
Dma {
channel: usize,
},
/// Advance device clocks to the next event while the CPU stays paused.
HaltIdle,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MachineError {
Cpu(CpuError),
Dma(DmaError),
}
impl fmt::Display for MachineError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Cpu(error) => write!(f, "{error}"),
Self::Dma(error) => write!(f, "{error}"),
}
}
}
impl Error for MachineError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Cpu(error) => Some(error),
Self::Dma(error) => Some(error),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FrameRunError {
Cpu(CpuError),
Dma(DmaError),
StepLimit(usize),
}
impl fmt::Display for FrameRunError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Cpu(error) => write!(f, "{error}"),
Self::Dma(error) => write!(f, "{error}"),
Self::StepLimit(limit) => write!(f, "no VBlank entry within {limit} machine steps"),
}
}
}
impl Error for FrameRunError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Cpu(error) => Some(error),
Self::Dma(error) => Some(error),
Self::StepLimit(_) => None,
}
}
}
/// Owns the CPU and memory bus. Use this instead of Cpu::step to advance device
/// clocks, execute DMA, honor HALT, and deliver interrupts. No audio or STOP yet.
pub struct Machine {
cpu: Cpu,
memory: Memory,
last_timing: StepTiming,
}
impl Machine {
pub fn new(cpu: Cpu, memory: Memory) -> Self {
Self {
cpu,
memory,
last_timing: StepTiming::default(),
}
}
pub fn cpu(&self) -> &Cpu {
&self.cpu
}
pub fn memory(&self) -> &Memory {
&self.memory
}
/// Host setup and inspection. Bus writes do not automatically advance time.
pub fn memory_mut(&mut self) -> &mut Memory {
&mut self.memory
}
pub fn halted(&self) -> bool {
self.memory.halted()
}
pub fn cycles(&self) -> u64 {
self.memory.cycles()
}
/// Execute through the next entry to scanline 160, even when already in VBlank.
/// Stops at the first instruction, DMA-unit, or HALT-idle boundary after the event.
/// DMA requested by that event may still be pending. Prior successful
/// steps remain committed on error or limit exhaustion. Zero steps always fails.
pub fn run_until_vblank(&mut self, max_steps: usize) -> Result<usize, FrameRunError> {
let previous = self.memory.display_position().vblanks;
for steps in 1..=max_steps {
self.step().map_err(|error| match error {
MachineError::Cpu(error) => FrameRunError::Cpu(error),
MachineError::Dma(error) => FrameRunError::Dma(error),
})?;
if self.memory.display_position().vblanks != previous {
return Ok(steps);
}
}
Err(FrameRunError::StepLimit(max_steps))
}
/// Cost of the last successful step. Failed steps leave this unchanged.
pub fn last_timing(&self) -> StepTiming {
self.last_timing
}
/// Service one ready DMA unit before sampling IRQ or executing CPU code.
/// Lower channel numbers take priority; higher-priority requests can preempt
/// between units. The CPU and its registers remain paused throughout DMA.
/// If HALT is still waiting, advance to the next display edge or timer overflow.
/// Wake-up ignores IME and CPSR.I; IRQ delivery still respects both masks.
/// 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, prefetch, and IRQ synchronization delays are not modeled.
/// CPU/DMA diagnostics leave CPU, devices, and the clock unchanged for this step.
/// Row-capture diagnostics are deferred to Memory::present_frame instead.
pub fn step(&mut self) -> Result<StepKind, MachineError> {
if let Some((channel, timing)) = self.memory.step_dma().map_err(MachineError::Dma)? {
self.last_timing = timing;
return Ok(StepKind::Dma { channel });
}
if self.memory.halted() {
let timing = StepTiming {
idle_cycles: self.memory.next_event_cycles(),
..StepTiming::default()
};
self.memory.advance_cycles(timing.total());
self.last_timing = timing;
return Ok(StepKind::HaltIdle);
}
let (kind, timing) = if self.cpu.take_interrupt(self.memory.irq_pending(), false) {
(StepKind::IrqEntry, self.cpu.exception_timing(&self.memory))
} else {
(
StepKind::Instruction,
self.cpu
.step_timed(&mut self.memory)
.map_err(MachineError::Cpu)?,
)
};
self.memory.advance_cycles(timing.total());
self.last_timing = timing;
Ok(kind)
}
}

126
src/timing.rs Normal file
View file

@ -0,0 +1,126 @@
//! Nominal GBA bus costs. These calculations do not perform memory accesses.
//! Game Pak prefetch, display-bus contention, and configurable EWRAM timing are not modeled.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessWidth {
Byte,
Halfword,
Word,
}
impl AccessWidth {
pub(crate) fn bytes(self) -> u32 {
match self {
Self::Byte => 1,
Self::Halfword => 2,
Self::Word => 4,
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum AccessKind {
NonSequential,
#[default]
Sequential,
}
/// Timing breakdown for one instruction, exception entry, DMA unit, or HALT batch.
/// DMA has zero code cycles and charges two internal cycles on its first unit.
/// This is a nominal estimate, not a cycle-accurate bus trace.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct StepTiming {
pub code_cycles: u32,
pub data_cycles: u32,
pub internal_cycles: u32,
/// Device-clock cycles while the CPU is halted, without CPU or DMA bus work.
pub idle_cycles: u32,
}
impl StepTiming {
pub fn total(self) -> u32 {
self.code_cycles + self.data_cycles + self.internal_cycles + self.idle_cycles
}
}
/// Compute an access cost from a WAITCNT snapshot. This does not validate the
/// address against the memory map. Unmapped regions retain a one-cycle fallback
/// so a branch can finish before the next instruction fetch reports a diagnostic.
/// WAITCNT's prefetch and PHI bits are stored but do not affect this calculation.
pub fn bus_cycles(waitcnt: u16, address: u32, width: AccessWidth, kind: AccessKind) -> u32 {
match address >> 24 {
0x02 => {
if width == AccessWidth::Word {
6
} else {
3
}
}
0x05 | 0x06 => {
if width == AccessWidth::Word {
2
} else {
1
}
}
0x08..=0x0d => {
let window = ((address >> 25) - 4) as usize;
let first_shift = [2, 5, 8][window];
let second_shift = [4, 7, 10][window];
let first = 1 + [4, 3, 2, 8][usize::from((waitcnt >> first_shift) & 3)];
let second = 1 + if waitcnt & (1 << second_shift) != 0 {
1
} else {
[2, 4, 8][window]
};
// The Game Pak bus forces N timing at each 128 KiB boundary.
let sequential = kind == AccessKind::Sequential && address & 0x1fffe != 0;
let cycles = if sequential { second } else { first };
// Each word is two halfwords. The second halfword always uses S timing.
cycles
+ if width == AccessWidth::Word {
second
} else {
0
}
}
// BIOS, IWRAM, I/O, and OAM use a 32-bit bus. Other areas remain unmapped.
_ => 1,
}
}
pub(crate) fn refill_cycles(waitcnt: u16, target: u32, width: AccessWidth) -> u32 {
bus_cycles(waitcnt, target, width, AccessKind::NonSequential)
+ bus_cycles(
waitcnt,
target.wrapping_add(width.bytes()),
width,
AccessKind::Sequential,
)
+ bus_cycles(
waitcnt,
target.wrapping_add(2 * width.bytes()),
width,
AccessKind::Sequential,
)
}
/// A data-only trace. Instruction fetches and branch refills are charged by the
/// CPU timing summary, not here. Actual CPU bus calls supply addresses and widths.
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct DataTiming {
pub cycles: u32,
next: Option<u32>,
}
impl DataTiming {
pub fn access(&mut self, waitcnt: u16, address: u32, width: AccessWidth) {
let kind = if self.next == Some(address) {
AccessKind::Sequential
} else {
AccessKind::NonSequential
};
self.cycles += bus_cycles(waitcnt, address, width, kind);
self.next = Some(address.wrapping_add(width.bytes()));
}
}