fix(timers): retain shared free-running prescaler phase

This commit is contained in:
0xrsydn 2026-10-06 22:04:59 +07:00
commit 27806ccf9e
13 changed files with 459 additions and 32 deletions

View file

@ -1,6 +1,6 @@
//! GBA I/O subset: DMA, timers, interrupts, WAITCNT, display, and keypad input.
//! Timer phase resets on enable or clock-source changes. Hardware startup delays,
//! shared prescaler phase, audio events, and interrupt delivery delays are not modeled.
//! Timers share a free-running prescaler phase. Hardware startup/write delays,
//! audio events, and interrupt delivery delays are not modeled.
mod timer_step;
pub(crate) use timer_step::TimerStep;
@ -67,7 +67,6 @@ struct Timer {
reload: u16,
counter: u16,
control: u8,
phase: u32,
}
impl Timer {
@ -102,25 +101,22 @@ impl Timer {
if start {
self.counter = self.reload;
}
if start || (self.control ^ value) & 7 != 0 {
self.phase = 0;
}
self.control = value;
}
/// Next independently clocked overflow. Cascaded timers receive their
/// pulses at a predecessor's event and cannot overflow earlier.
fn next_overflow(&self) -> Option<u32> {
fn next_overflow(&self, phase: u16) -> Option<u32> {
if !self.enabled() || self.count_up() {
return None;
}
let divisor = [1, 64, 256, 1024][usize::from(self.control & 3)];
Some((0x1_0000 - u32::from(self.counter)) * divisor - self.phase)
Some((0x1_0000 - u32::from(self.counter)) * divisor - u32::from(phase) % divisor)
}
/// Advance without looping once per cycle or overflow. Return the number of
/// overflows, not just a boolean, so cascaded timers receive every pulse.
fn advance(&mut self, cycles: u32, previous_overflows: u64) -> u64 {
fn advance(&mut self, cycles: u32, previous_overflows: u64, phase: u16) -> u64 {
if !self.enabled() {
return 0;
}
@ -128,9 +124,9 @@ impl Timer {
previous_overflows
} else {
let divisor = [1, 64, 256, 1024][usize::from(self.control & 3)];
let elapsed = u64::from(self.phase) + u64::from(cycles);
self.phase = (elapsed % divisor) as u32;
elapsed / divisor
// Count shared divider edges in (start, end], independently of enable time.
let start = u64::from(phase);
(start + u64::from(cycles)) / divisor - start / divisor
};
let until_overflow = 0x1_0000 - u64::from(self.counter);
if ticks < until_overflow {
@ -155,6 +151,8 @@ enum PowerState {
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct Io {
timers: [Timer; 4],
// Low ten system-clock bits cover every supported divider. STOP freezes this phase.
timer_phase: u16,
enable: u16,
pending: u16,
master_enable: bool,
@ -320,7 +318,7 @@ impl Io {
pub(crate) fn next_event_cycles(&self) -> u32 {
self.timers
.iter()
.filter_map(Timer::next_overflow)
.filter_map(|timer| timer.next_overflow(self.timer_phase))
.fold(self.display.next_event_cycles(), u32::min)
}
@ -404,19 +402,22 @@ impl Io {
pub(crate) fn commit_timer_step(&mut self, step: TimerStep) {
self.timers = step.timers;
self.timer_phase = step.phase;
self.pending = (self.pending & !TIMER_IRQ_MASK) | step.pending;
self.wake_if_requested();
}
fn advance_timer_bank(timers: &mut [Timer; 4], cycles: u32) -> u16 {
fn advance_timer_bank(timers: &mut [Timer; 4], phase: &mut u16, cycles: u32) -> u16 {
let mut pending = 0;
let mut overflows = 0;
for (index, timer) in timers.iter_mut().enumerate() {
overflows = timer.advance(cycles, overflows);
overflows = timer.advance(cycles, overflows, *phase);
if overflows != 0 && timer.control & 0x40 != 0 {
pending |= 1 << (3 + index);
}
}
// Advance even when every timer is disabled; writes never reset this clock.
*phase = ((u64::from(*phase) + u64::from(cycles)) & 1023) as u16;
pending
}
@ -453,7 +454,8 @@ impl Io {
self.dma.trigger(vblank, hblank);
self.pending |= self.display.advance(cycles);
if advance_timers {
self.pending |= Self::advance_timer_bank(&mut self.timers, cycles);
self.pending |=
Self::advance_timer_bank(&mut self.timers, &mut self.timer_phase, cycles);
}
self.wake_if_requested();
}

View file

@ -7,6 +7,7 @@ pub(super) const TIMER_IRQ_MASK: u16 = 0x78;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TimerStep {
pub(super) timers: [Timer; 4],
pub(super) phase: u16,
pub(super) pending: u16,
elapsed: u32,
}
@ -15,6 +16,7 @@ impl TimerStep {
pub(super) fn new(io: &Io) -> Self {
Self {
timers: io.timers,
phase: io.timer_phase,
pending: io.pending & TIMER_IRQ_MASK,
elapsed: 0,
}
@ -22,7 +24,8 @@ impl TimerStep {
pub(crate) fn advance_to(&mut self, elapsed: u32) {
assert!(elapsed >= self.elapsed, "timer step time must be monotonic");
self.pending |= Io::advance_timer_bank(&mut self.timers, elapsed - self.elapsed);
self.pending |=
Io::advance_timer_bank(&mut self.timers, &mut self.phase, elapsed - self.elapsed);
self.elapsed = elapsed;
}

View file

@ -322,6 +322,78 @@ fn bios_power_store_pays_its_cycles_and_new_timer_irq_wakes_only_halt() {
}
}
#[test]
fn machine_enable_joins_the_shared_phase_and_commits_it_without_duplicate_ticks() {
let (cpu, mut memory) = prepared(
false,
&[0xe581_0000, 0xe591_2000],
&[(0, 0x0081_0000), (1, TIMER_BASE)],
);
memory.advance_cycles(56); // The disabled divider still runs.
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap(); // Source 6 + store 1: enable at t=63.
assert_eq!(machine.cycles(), 63);
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 0);
machine.step().unwrap(); // N source 8 + read 1 + internal 1: t=73.
assert_eq!(machine.cpu().registers()[2], 0x0081_0001);
assert_eq!(machine.cycles(), 73);
machine.memory_mut().advance_cycles(54);
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 1);
machine.memory_mut().advance_cycles(1);
assert_eq!(machine.memory().read16(TIMER_BASE).unwrap(), 2);
}
#[test]
fn failed_cpu_and_dma_work_cannot_consume_a_shared_prescaler_edge() {
let (cpu, mut memory) = prepared(false, &[0xe891_0005], &[(1, TIMER_BASE + 12)]);
start(&mut memory, 0, 0xffff, 0xc1);
memory.advance_cycles(63);
let before = memory.io.timer_step();
let mut machine = Machine::new(cpu, memory);
assert!(machine.step().is_err()); // First data read succeeds after the t=64 overflow; second fails.
assert_eq!(machine.memory().io.timer_step(), before);
assert_eq!(machine.cycles(), 63);
dma(machine.memory_mut(), 0x0e00_0000, RAM, 1);
assert!(machine.step().is_err());
assert_eq!(machine.memory().io.timer_step(), before);
machine.memory_mut().advance_cycles(1);
assert_eq!(machine.memory().read16(IF).unwrap(), 8);
}
#[test]
fn dma_timer_phases_share_the_divider_with_later_host_clock_advances() {
let mut memory = Memory::new(Vec::new()).unwrap();
memory.advance_cycles(60);
start(&mut memory, 0, 0, 0x81);
dma(&mut memory, TIMER_BASE, RAM, 1);
memory.step_dma().unwrap().unwrap(); // Read t=63, destination t=64.
assert_eq!(memory.read32(RAM).unwrap(), 0x0081_0000);
assert_eq!(memory.read16(TIMER_BASE).unwrap(), 1);
memory.write32(RAM, 0x0081_0000).unwrap();
dma(&mut memory, RAM, TIMER_BASE + 4, 1);
memory.step_dma().unwrap().unwrap(); // Enable timer1 at t=68, not at a new divider origin.
memory.advance_cycles(59);
assert_eq!(memory.cycles(), 127);
assert_eq!(memory.read16(TIMER_BASE + 4).unwrap(), 0);
memory.advance_cycles(1);
assert_eq!(memory.read16(TIMER_BASE).unwrap(), 2);
assert_eq!(memory.read16(TIMER_BASE + 4).unwrap(), 1);
}
#[test]
fn halt_after_a_machine_timer_start_uses_the_committed_shared_phase() {
let (cpu, mut memory) = prepared(false, &[0xe581_0000], &[(0, 0x00c1_ffff), (1, TIMER_BASE)]);
memory.advance_cycles(56);
memory.write16(IE, 8).unwrap();
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap(); // Enable at t=63.
machine.memory_mut().write8(HALTCNT, 0).unwrap();
assert_eq!(machine.step().unwrap(), StepKind::HaltIdle);
assert_eq!(machine.last_timing().idle_cycles, 1);
assert_eq!(machine.cycles(), 64);
assert!(!machine.halted());
}
#[test]
fn halt_and_stop_keep_their_clock_ownership() {
for stop in [false, true] {

View file

@ -0,0 +1,230 @@
//! Original shared-divider checks. Startup and register-write delays remain outside this model.
use gba_core::{
cpu::Cpu,
input::Buttons,
io::{HALTCNT, IE, IF, KEYCNT, TIMER_BASE},
machine::{Machine, StepKind},
memory::{Memory, ROM_START},
};
const DIVISORS: [u64; 4] = [1, 64, 256, 1024];
fn start(memory: &mut Memory, timer: u32, reload: u16, control: u16) {
memory
.write32(
TIMER_BASE + timer * 4,
u32::from(reload) | u32::from(control) << 16,
)
.unwrap();
}
#[test]
fn every_shared_phase_selects_the_next_edge_without_replaying_the_current_edge() {
for phase in 0..1024_u32 {
let mut memory = Memory::new(Vec::new()).unwrap();
memory.advance_cycles(phase); // The divider runs with all timers disabled.
for selection in 0..4 {
start(&mut memory, selection, 0, 0x80 | selection as u16);
}
memory.advance_cycles(0);
for selection in 0..4 {
assert_eq!(memory.read16(TIMER_BASE + selection * 4).unwrap(), 0);
}
// Cover two complete divider periods after every possible enable phase.
let mut elapsed = 0;
for delta in [1, 62, 1, 191, 1, 767, 1, 1024] {
memory.advance_cycles(delta);
elapsed += delta;
for (selection, divisor) in DIVISORS.into_iter().enumerate() {
let expected = (u64::from(phase + elapsed) / divisor) - u64::from(phase) / divisor;
assert_eq!(
memory.read16(TIMER_BASE + selection as u32 * 4).unwrap(),
expected as u16,
"phase={phase}, elapsed={elapsed}, divisor={divisor}"
);
}
}
}
}
#[test]
fn staggered_timers_share_ticks_and_cascade_ignores_prescaler_selection() {
let mut memory = Memory::new(Vec::new()).unwrap();
memory.advance_cycles(10);
start(&mut memory, 0, 0xfffe, 0xc1);
memory.advance_cycles(15);
start(&mut memory, 1, 0xfffe, 0xc1);
memory.advance_cycles(38);
start(&mut memory, 2, 0xfffe, 0xc1);
start(&mut memory, 3, 0xffff, 0xc7); // Cascaded, /1024 field ignored.
memory.advance_cycles(1); // All independent timers tick at system cycle 64.
for index in 0..3 {
assert_eq!(memory.read16(TIMER_BASE + index * 4).unwrap(), 0xffff);
}
assert_eq!(memory.read16(IF).unwrap(), 0);
memory.advance_cycles(64);
for index in 0..3 {
assert_eq!(memory.read16(TIMER_BASE + index * 4).unwrap(), 0xfffe);
}
assert_eq!(memory.read16(TIMER_BASE + 12).unwrap(), 0xffff);
assert_eq!(memory.read16(IF).unwrap(), 0x78);
}
#[test]
fn leaving_cascade_joins_the_existing_divider_instead_of_starting_a_new_period() {
let mut memory = Memory::new(Vec::new()).unwrap();
start(&mut memory, 1, 0, 0x85); // Timer0 disabled: no cascade pulses.
memory.advance_cycles(63);
memory.write16(TIMER_BASE + 6, 0x81).unwrap();
memory.advance_cycles(1);
assert_eq!(memory.read16(TIMER_BASE + 4).unwrap(), 1);
memory.write16(TIMER_BASE + 6, 0x85).unwrap();
memory.advance_cycles(63);
memory.write16(TIMER_BASE + 6, 0x81).unwrap();
memory.advance_cycles(1);
assert_eq!(memory.read16(TIMER_BASE + 4).unwrap(), 2);
}
#[test]
fn halt_stops_at_the_shared_edge_and_stop_freezes_the_divider_until_keypad_wake() {
let mut memory = Memory::new(Vec::new()).unwrap();
memory.advance_cycles(63);
start(&mut memory, 0, 0xffff, 0xc1);
memory.write16(IE, 8).unwrap();
memory.write8(HALTCNT, 0).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), memory);
assert_eq!(machine.step().unwrap(), StepKind::HaltIdle);
assert_eq!(machine.last_timing().idle_cycles, 1);
assert_eq!(machine.cycles(), 64);
assert_eq!(machine.memory().read16(IF).unwrap(), 8);
assert!(!machine.halted());
let mut memory = Memory::new(Vec::new()).unwrap();
memory.advance_cycles(63);
start(&mut memory, 0, 0, 0x81);
memory.write16(IE, 0x1000).unwrap();
memory.write16(KEYCNT, 0x4001).unwrap();
memory.write8(HALTCNT, 0x80).unwrap();
memory.advance_cycles(1000);
assert_eq!(memory.cycles(), 63);
memory.set_buttons(Buttons::from_bits(1));
assert!(!memory.stopped());
memory.advance_cycles(1);
assert_eq!(memory.cycles(), 64);
assert_eq!(memory.read16(TIMER_BASE).unwrap(), 1);
}
#[test]
fn maximum_batches_keep_low_divider_bits_without_per_cycle_iteration() {
let mut memory = Memory::new(Vec::new()).unwrap();
memory.advance_cycles(u32::MAX); // Low ten bits are all set, even with no enabled timer.
start(&mut memory, 0, 0, 0x83);
memory.advance_cycles(1);
assert_eq!(memory.read16(TIMER_BASE).unwrap(), 1);
memory.advance_cycles(u32::MAX); // 4,194,303 ticks, wrapping the 16-bit counter to zero.
assert_eq!(memory.read16(TIMER_BASE).unwrap(), 0);
memory.advance_cycles(1);
assert_eq!(memory.read16(TIMER_BASE).unwrap(), 1);
assert_eq!(memory.cycles(), 2 * (u64::from(u32::MAX) + 1));
}
#[derive(Clone, Copy, Default)]
struct ReferenceTimer {
counter: u16,
reload: u16,
control: u16,
}
struct Reference {
timers: [ReferenceTimer; 4],
clock: u64,
pending: u16,
}
impl Reference {
fn control(&mut self, index: usize, value: u16) {
let timer = &mut self.timers[index];
let value = value & if index == 0 { 0xc3 } else { 0xc7 };
if timer.control & 0x80 == 0 && value & 0x80 != 0 {
timer.counter = timer.reload;
}
timer.control = value;
}
fn advance(&mut self, cycles: u32) {
// Independent literal clock-edge simulation. No phase accumulator or bulk division.
for _ in 0..cycles {
self.clock += 1;
let mut overflow = false;
for (index, timer) in self.timers.iter_mut().enumerate() {
let tick = timer.control & 0x80 != 0
&& if timer.control & 4 != 0 {
overflow
} else {
self.clock
.is_multiple_of(DIVISORS[usize::from(timer.control & 3)])
};
overflow = tick && timer.counter == 0xffff;
if overflow {
timer.counter = timer.reload;
if timer.control & 0x40 != 0 {
self.pending |= 1 << (index + 3);
}
} else if tick {
timer.counter += 1;
}
}
}
}
}
#[test]
fn dynamic_control_reload_and_clock_sequences_match_independent_edge_simulation() {
for mut seed in [0x1020_3040_u32, 0xdead_beef, 0x7654_3210] {
let mut memory = Memory::new(Vec::new()).unwrap();
let mut reference = Reference {
timers: [ReferenceTimer::default(); 4],
clock: 0,
pending: 0,
};
for _ in 0..2000 {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
let index = ((seed >> 8) & 3) as usize;
let address = TIMER_BASE + index as u32 * 4;
match seed >> 29 {
0 | 1 => {
let value = ((seed >> 16) as u16 & 0xc7) | 0x80;
memory.write16(address + 2, value).unwrap();
reference.control(index, value);
}
2 => {
memory.write16(address + 2, 0).unwrap();
reference.control(index, 0);
}
3 => {
let reload = 0xfff0 | ((seed >> 16) as u16 & 15);
memory.write16(address, reload).unwrap();
reference.timers[index].reload = reload;
}
4 => {
let mask = (seed >> 16) as u16 & 0x78;
memory.write16(IF, mask).unwrap();
reference.pending &= !mask;
}
_ => {
let cycles = (seed >> 16) & 2047;
memory.advance_cycles(cycles);
reference.advance(cycles);
}
}
for (index, timer) in reference.timers.iter().enumerate() {
assert_eq!(
memory.read32(TIMER_BASE + index as u32 * 4).unwrap(),
u32::from(timer.counter) | u32::from(timer.control) << 16
);
}
assert_eq!(memory.read16(IF).unwrap(), reference.pending);
assert_eq!(memory.cycles(), reference.clock);
}
}
}

View file

@ -100,7 +100,7 @@ fn all_prescalers_retain_fractional_cycles_between_advances() {
}
#[test]
fn clock_source_changes_and_restart_reset_provisional_phase() {
fn clock_source_changes_and_restart_keep_the_shared_phase() {
let mut bus = memory();
bus.write32(TIMER_BASE, 0x0081_0000).unwrap(); // /64
bus.advance_cycles(63);
@ -108,16 +108,17 @@ fn clock_source_changes_and_restart_reset_provisional_phase() {
bus.advance_cycles(1);
assert_eq!(bus.read16(TIMER_BASE).unwrap(), 1);
bus.advance_cycles(63);
bus.write16(TIMER_BASE + 2, 0x82).unwrap(); // /256 resets phase
bus.advance_cycles(255);
bus.write16(TIMER_BASE + 2, 0x82).unwrap(); // t=127: next shared /256 edge is t=256.
bus.advance_cycles(128);
assert_eq!(bus.read16(TIMER_BASE).unwrap(), 1);
bus.advance_cycles(1);
assert_eq!(bus.read16(TIMER_BASE).unwrap(), 2);
bus.write16(TIMER_BASE + 2, 0).unwrap();
bus.advance_cycles(255);
bus.advance_cycles(255); // t=511; disabled time still advances the divider.
bus.write16(TIMER_BASE + 2, 0x82).unwrap();
bus.advance_cycles(255);
assert_eq!(bus.read16(TIMER_BASE).unwrap(), 0);
bus.advance_cycles(1);
assert_eq!(bus.read16(TIMER_BASE).unwrap(), 1);
}
#[test]

View file

@ -48,7 +48,7 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/src/cpu/fetch_history_tests.rs` | Shared sample consumption, no execution-entry rereads, and failed-step retry |
| `crates/gba-core/src/cpu/fetch_boundary_tests.rs` | Actual fetch-region snapshots, split refills, IWRAM lane requirements, ROM-window boundaries, and nominal timing |
| `crates/gba-core/src/io.rs` | I/O registers, timers, HALT/STOP wake-up, next-event bounds, and IRQ latches |
| `crates/gba-core/src/io/timer_step.rs` | Staged timer bank and timer IF bits at ordered CPU/DMA bus phases |
| `crates/gba-core/src/io/timer_step.rs` | Staged timer bank, shared divider phase, and timer IF bits at ordered CPU/DMA bus phases |
| `crates/gba-core/src/memory/timer_step_tests.rs` | Timer access phases, coherent lanes, cascades, IF ordering, rollback, DMA, idle, and clock ownership |
| `crates/gba-core/src/display.rs` | Display clock, scanline status, comparison edges, and display IRQ events |
| `crates/gba-core/src/dma.rs` | DMA registers, internal pointers, retained channel data, trigger state, priority, and completion IRQs |
@ -132,6 +132,7 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/tests/timers.rs` | Timer rules, interrupt registers, and a cycle-by-cycle reference |
| `crates/gba-core/tests/machine.rs` | Device IRQ entry/return, clock policy, and I/O failure atomicity |
| `crates/gba-core/tests/timing.rs` | WAITCNT fields, bus costs, and timer/IRQ timing integration |
| `crates/gba-core/tests/timer_prescaler.rs` | Exhaustive divider phases, staggered starts, control/cascade transitions, independent edge simulation, large batches, and idle ownership |
| `crates/gba-core/tests/keypad.rs` | Keypad register widths, IRQ sampling, CPU/DMA ordering, HALT wake-up, diagnostics, and BIOS IntrWait |
| `crates/gba-core/tests/graphics.rs` | Video memory, rendering, KEYINPUT, and CPU-driven graphics integration |
| `crates/gba-core/tests/tiles.rs` | Mode 0 registers, maps, palettes, composition, diagnostics, and CPU tile/sprite demo |

View file

@ -422,4 +422,4 @@ Important timing limits:
- Game Pak prefetch is nominal. Full-buffer restart, page boundaries, cancellation, and live WAITCNT changes need independent hardware validation.
- PHI and SRAM wait fields are stored; PHI output and SRAM mapping are not implemented.
- External work RAM timing is fixed. The undocumented memory-control register is not implemented.
- Exact DMA startup/resumption delays, display-bus contention, shared timer prescaler phase, and timer startup delays remain unmodeled.
- Exact DMA startup/resumption delays, display-bus contention, and timer startup/register-write delays remain unmodeled.

View file

@ -34,7 +34,8 @@ The transaction also owns timer IF bits. Reads see overflows through the current
A later internal cycle can raise an acknowledged timer request again. Non-timer IF bits retain their existing behavior.
IRQ delivery still waits until the next machine step. A committed timer request can wake HALT, but not STOP.
Successful steps commit timers and timer IF bits. Failed steps discard staged counters, prescaler phases, reload/control writes, and requests.
Successful steps commit timers, the shared divider phase, and timer IF bits.
Failed steps discard staged counters, divider progress, reload/control writes, and requests.
Display and other device clocks then advance once, without advancing timers again.
Those devices still use the bulk scheduler. Mid-instruction display events, general bus arbitration, and IRQ synchronization delays remain unmodeled.
Host clock advances and HALT idle batches retain ordinary bulk timer advancement; STOP idle does not advance timers.
@ -81,8 +82,16 @@ Entering IRQ mode does not clear IF. Software must acknowledge the request.
Unused register bits read as zero. Unknown I/O addresses and mirrors remain unmapped.
Byte, halfword, and word accesses are supported for this register subset.
Timer startup delays and shared prescaler phase are not modeled.
The provisional timer model resets its private prescaler phase on enable or clock-source changes.
Independent timers share a free-running divider with a synthetic zero phase at reset.
The divider advances even when all timers are disabled. Enable, restart, and clock-source changes do not reset it.
A timer receives selected divider edges after the start of an interval and through its end.
Changing out of cascade mode uses the existing shared phase; cascade mode ignores the divider selection.
HALT next-event bounds use that phase. STOP freezes it, and wake resumes without resetting it.
CPU/DMA timer transactions stage divider progress and commit it only on success, without a second bulk tick.
See [prescaler evidence and original tests](../research/timer-prescaler.md).
Hardware startup and register-write delays are still not modeled. Writes retain the immediate bus-completion policy.
The shared-phase tests validate this bounded model, not absolute enable/reload edges on hardware.
Audio events and interrupt sources other than timers, display events, DMA completion, and keypad input are not implemented.
`--timer-demo` executes original ARM code that configures Timer 0, IE, and IME.
@ -207,7 +216,7 @@ A later running-state input sample follows the normal keypad IRQ policy.
Host/debug writes to IE or KEYCNT can also satisfy the wake condition; stopped CPU code cannot execute such writes.
After wake-up, ready DMA again takes priority, then IRQ delivery or CPU execution resumes.
Timer prescaler phases, display position, RAM, and completed captured frames are retained, not reset.
The shared timer divider phase, display position, RAM, and completed captured frames are retained, not reset.
STOP does not automatically set forced blank or clear DISPCNT.
Software can force blank before STOP if it wants the displayed image disabled.

View file

@ -223,6 +223,14 @@ Ordered timer data accesses:
See [staged timer clock ownership](research/ordered-timer-access.md). Relative probe agreement does not establish all absolute timer edges.
Shared timer prescaler phase:
- [Jgenesis timers at fab6e2cc](https://github.com/jsgroth/jgenesis/blob/fab6e2ccc60e492dd68b7f1e927b0829a6d80195/backend/gba-core/src/timers.rs), for absolute-clock divider edges and separate pending-write/enable state.
- [NanoBoyAdvance timer logic at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/timer/timer.cc) and [channel definitions](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/timer/timer.hh), for shared timestamp alignment and startup overflow corner cases.
- Search leads: [VBA-M prescaler change](https://github.com/visualboyadvance-m/visualboyadvance-m/commit/92154dcd2ef7fca8c8afa8fced2f350ad2d2452a) and [mGBA timer suite](https://github.com/mgba-emu/suite/blob/04ada216ee13c56d786e54636ac980a71d791145/src/timers.c). Neither was used as a local hardware-test oracle.
See [shared divider scope and tests](research/timer-prescaler.md). The correction does not implement delayed register writes or startup edges.
Use [GBATEK](https://problemkaputt.de/gbatek.htm) and ARM7TDMI documentation for further hardware work.
Only use game ROMs that you may lawfully use. Do not commit game ROMs, BIOS files, or game assets.
The ignore file excludes `roms/`, common game ROM extensions, and save files.

View file

@ -28,7 +28,8 @@ Those remain explicit validation limits.
## Staged clock ownership
`Memory` owns an optional `TimerStep` during each machine instruction, IRQ entry, or DMA unit.
The transaction copies only the four timers and timer IF bits, not RAM, display state, or the entire I/O object.
The transaction copies the four timers, shared divider phase, and timer IF bits, not RAM or the entire I/O object.
The later [shared-prescaler correction](timer-prescaler.md) replaced private timer remainders with this common divider.
Its elapsed timestamp starts at zero and increases monotonically.
It has no heap allocation or event log.
@ -44,7 +45,7 @@ One word access has one sampling time for all four byte lanes.
Reload bytes merge before control bytes at that same time.
Block transfers have separate completion times for successive words.
A start write cannot count earlier source cycles. A stop write includes clocks through its completion.
Prescaler remainders, cascaded overflow pulses, and reload behavior use the existing timer implementation.
Shared divider edges, cascaded overflow pulses, and reload behavior use the timer-bank implementation.
Timer IF reads include requests raised through the access phase.
Acknowledgement clears timer requests already present at that phase; later work can raise them again.
@ -63,7 +64,7 @@ This extends the existing whole-unit scheduler; it does not establish physical D
### Errors and API isolation
CPU diagnostics discard staged time, counter values, prescaler remainders, and pending timer requests.
CPU diagnostics discard staged time, counter values, shared divider progress, and pending timer requests.
This includes failures before instruction fetch and failures after earlier block-load accesses.
Existing store validation still prevents partial writes when a block transfer has an invalid destination.
Previously completed machine steps remain committed.

View file

@ -0,0 +1,94 @@
# Shared timer prescaler phase
## Result and scope
All four timers now derive independent-clock ticks from one free-running system-clock divider.
Enabling, disabling, restarting, or changing a timer's clock source does not reset that divider.
This replaces the previous per-timer phase, which restarted on enable or clock-source changes.
The correction covers divider ownership and edge selection in the current immediate-write timer model.
It does **not** add hardware startup delays, delayed register writes, or IRQ synchronization delays.
Those behaviors need separate edge tests; adding a fixed startup delay alone would be incomplete.
No physical-hardware measurement or external-emulator differential run was performed locally.
## Evidence reviewed
- [Jgenesis timers at fab6e2cc](https://github.com/jsgroth/jgenesis/blob/fab6e2ccc60e492dd68b7f1e927b0829a6d80195/backend/gba-core/src/timers.rs) derive ticks from absolute clock values shifted by the selected divisor.
Pending register writes and newly enabled reloads have separate handling.
- [NanoBoyAdvance timers at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/timer/timer.cc) derive prescaler alignment from the scheduler timestamp.
Its [channel definitions](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/timer/timer.hh) separate pending writes from active timer control.
The implementation also handles a possible overflow from an old `0xffff` counter during enable/reload timing.
Both implementations use a common clock origin rather than a private period restarted for each timer.
Their startup handling also shows why divider phase and enable delay must be treated separately.
The current correction uses the shared-clock rule without claiming their complete transition behavior.
No implementation code was imported.
Search also identified a [VBA-M prescaler change](https://github.com/visualboyadvance-m/visualboyadvance-m/commit/92154dcd2ef7fca8c8afa8fced2f350ad2d2452a)
and the [mGBA timer suite](https://github.com/mgba-emu/suite/blob/04ada216ee13c56d786e54636ac980a71d791145/src/timers.c).
These remain follow-up leads, not local test passes or implementation oracles.
## Implemented divider
Synthetic reset starts the shared phase at zero.
Only the low ten system-clock bits are needed for divisors 1, 64, 256, and 1024.
The phase advances with emulated time even when all timers are disabled or cascaded.
It does not depend on host wall time or display-frame position.
For an interval from clock `start` to clock `end`, an independently clocked timer receives:
```text
ticks = floor(end / divisor) - floor(start / divisor)
```
This counts divider edges after the starting timestamp and through the ending timestamp.
An enable at an already completed edge does not replay that edge.
A clock-source change selects another output of the same divider, not a new clock origin.
Leaving cascade mode resumes from that existing divider position.
Cascade mode still counts predecessor overflows and ignores the prescaler selection field.
The core counts ticks and overflow pulses in bulk without iterating once per cycle.
Wide arithmetic handles maximum `u32` clock batches before retaining the low divider bits.
Overflow scheduling subtracts the current shared remainder, so HALT can stop at the next actual divider edge.
For example, under the current immediate-enable policy, a /64 timer enabled at clock 63 ticks at clock 64.
The old private-phase model incorrectly waited until clock 127.
This example defines the implemented no-delay subset, not a claim about an enable write adjacent to a hardware startup edge.
## Clock ownership and rollback
The divider belongs to the timer bank, not an individual timer.
Each machine `TimerStep` copies the shared phase with the counters and timer IF bits.
CPU and DMA bus phases advance that copy. Success commits it; a failed step discards it.
Other devices' bulk updates do not advance the committed divider again.
Host clock advances and HALT idle periods advance the same divider.
STOP freezes it, and keypad wake resumes the retained phase.
Host register access and CPU-only stepping do not advance time or reset the divider.
See [ordered timer accesses](ordered-timer-access.md) for transaction and API ownership.
## Original regression coverage
Tests exercise every low-ten-bit phase and all divider selections, including zero-cycle advances and completed-edge enables.
Staggered timer starts verify shared edges. Clock-source changes, restarts, and cascade transitions preserve the common origin.
A literal cycle-by-cycle reference checks randomized control, reload, acknowledgement, and clock sequences.
It uses absolute edge tests, not the implementation's phase accumulator or bulk overflow division.
Maximum-size batches check bounded progress and divider wrap.
Machine tests cross a shared edge during CPU stores, timer loads, and DMA source/destination phases.
Failed CPU and DMA steps must not consume that edge or retain its IRQ request.
HALT after a machine timer start must use the committed phase for its next-event bound.
STOP/keypad tests verify that stopped time does not move the divider.
## Validation and remaining work
Workspace/core/demo tests pass in debug and release on Darwin arm64.
Formatting, clippy, rustdoc, Python tests, native ROM windows, and all graphics smoke modes pass.
The release executable remains native Mach-O arm64.
Public ARM, Thumb, memory, and BIOS reports remain byte-identical to the preceding ordered-timer change.
The timer demo trace and both profiles of the cancellation probe are unchanged.
The probe still matches all its published observations, but it uses divisor one and does not validate prescaler startup edges.
Next, use independent timer-edge observations to define delayed reload/control writes and enable behavior.
Include overflow from the previous counter, cascade transitions, same-cycle reads, and IRQ timing before adding startup delays.
Full-buffer prefetch restart and page-boundary validation remain separate work.

View file

@ -35,6 +35,7 @@ Update this file when a feature lands or a limit is removed.
- CPU interrupt entry, interrupt masks, and FIQ priority.
- GBA interrupt enable (`IE`), request flags (`IF`), and master enable (`IME`) registers.
- Four 16-bit timers, with reloads, prescalers, count-up cascading, and timer-generated IRQs.
- A shared free-running timer divider survives enable and clock-source changes; HALT advances it and STOP freezes it.
- 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.
- Halfword/word DMA, address controls, repeat, channel priority, CPU pausing, and completion IRQs.
@ -175,7 +176,8 @@ Writes to cartridge addresses return errors instead of modeling cartridge hardwa
The desktop runner still uses only the original BIOS. Keep protected reads image-derived for all supplied images.
2. Validate the active nominal Game Pak prefetch queue against independent timing tests.
The [cancellation probe](research/prefetch-cancellation.md) now matches after [ordered timer accesses](research/ordered-timer-access.md).
Validate absolute timer startup/control edges and extend ordered observations to other devices without duplicate clocks or lost rollback.
The [shared timer prescaler](research/timer-prescaler.md) now retains a common clock origin across enable and clock-source changes.
Validate delayed timer startup/control edges and extend ordered observations to other devices without duplicate clocks or lost rollback.
Validate full-buffer restart, page boundaries, live WAITCNT transitions, and cold-start costs.
See [prefetch behavior and evidence](research/gamepak-prefetch.md); exact bus arbitration and timer/IRQ delays remain separate work.
3. Validate keypad retrigger behavior with hardware tests and add remaining device registers; extend BIOS reset coverage as sound/serial support becomes available.

View file

@ -288,6 +288,10 @@ They verify timer IF reads/acknowledgements, preservation of other IF bits, defe
Failures before fetch and after partial data work must discard staged time and requests. CPU-only APIs must not advance timer devices.
Display-capture splits must not tick committed timers a second time. See [ordered timer accesses](research/ordered-timer-access.md).
Bulk timer advancement is compared against an independent cycle-by-cycle reference for each prescaler and cascade configuration.
Shared-divider tests cover every low-ten-bit phase, staggered enables, source changes, restarts, and cascade transitions.
A separate literal edge simulation checks randomized control/reload/clock sequences without a per-timer phase accumulator.
Maximum clock batches, HALT bounds, STOP/keypad retention, and CPU/DMA rollback across divider edges have original regressions.
See [shared timer prescaler scope](research/timer-prescaler.md); delayed write and startup behavior remains unverified.
Machine tests cover ARM/Thumb IRQ return, pending-request gating, acknowledgement, and the complete timer demo.
Failed I/O block transfers are checked for partial register writes, hidden reload changes, and unintended IF acknowledgement.
Status tests cover all modes, flag combinations, interrupt masks, field selection, privilege rules, and bank isolation.