feat(serial): model disconnected multiplayer configuration

This commit is contained in:
0xrsydn 2026-10-07 05:02:23 +07:00
commit c5e19baed4
14 changed files with 500 additions and 44 deletions

View file

@ -7,7 +7,10 @@ mod serial;
use crate::audio::Audio;
pub use crate::audio::{FIFO_A, FIFO_B, SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, SOUND_START, WAVE_RAM};
pub(crate) use serial::Serial;
pub use serial::{JOYCNT, JOY_RECV, JOY_TRANS, RCNT, SIOCNT, SIODATA32, SIODATA8};
pub use serial::{
JOYCNT, JOY_RECV, JOY_TRANS, RCNT, SIOCNT, SIODATA32, SIODATA8, SIOMLT_SEND, SIOMULTI0,
SIOMULTI1, SIOMULTI2, SIOMULTI3,
};
mod timer_step;
pub(crate) use timer_step::TimerStep;
use timer_step::STAGED_IRQ_MASK;

View file

@ -1,9 +1,14 @@
//! Disconnected normal serial shifter with nominal internal clocks.
//! Disconnected serial: normal shifting and idle multiplayer child configuration.
use super::replace_byte;
pub const SIODATA32: u32 = 0x0400_0120;
pub const SIOMULTI0: u32 = SIODATA32;
pub const SIOMULTI1: u32 = 0x0400_0122;
pub const SIOMULTI2: u32 = 0x0400_0124;
pub const SIOMULTI3: u32 = 0x0400_0126;
pub const SIOCNT: u32 = 0x0400_0128;
pub const SIODATA8: u32 = 0x0400_012a;
pub const SIOMLT_SEND: u32 = SIODATA8;
pub const RCNT: u32 = 0x0400_0134;
pub const JOYCNT: u32 = 0x0400_0140;
pub const JOY_RECV: u32 = 0x0400_0150;
@ -11,8 +16,9 @@ pub const JOY_TRANS: u32 = 0x0400_0154;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Serial {
data: u32,
send: u8,
data: u32, // Shared SIODATA32 / SIOMULTI0-1 lanes.
multi_extra: u32, // SIOMULTI2-3; no connected parent can update these.
send: u16, // Shared low byte; high byte is accessible only in multiplayer format.
control: u16,
rcnt: u16,
bits_left: u8,
@ -22,14 +28,14 @@ pub(crate) struct Serial {
impl Serial {
pub(crate) fn mapped(address: u32) -> bool {
matches!(address,
SIODATA32..=0x0400_0123 | SIOCNT..=0x0400_012b |
SIODATA32..=0x0400_012b |
RCNT..=0x0400_0135 | JOYCNT..=0x0400_0141 |
JOY_RECV..=0x0400_0157)
}
pub(crate) fn unsupported(address: u32, value: u8) -> Option<&'static str> {
match address {
0x0400_0129 if value & 0x20 != 0 => Some("multiplayer/UART serial mode"),
0x0400_0129 if value & 0x30 == 0x30 => Some("UART serial mode"),
0x0400_0135 if value & 0xc0 == 0xc0 => Some("Joybus serial mode"),
0x0400_0135 if value & 0x81 == 0x81 => Some("GPIO serial interrupt enable"),
JOYCNT if value & 0x40 != 0 => Some("Joybus interrupt enable"),
@ -38,8 +44,14 @@ impl Serial {
}
}
fn gpio(&self) -> bool {
self.rcnt & 0x8000 != 0
}
fn multiplayer_format(&self) -> bool {
self.control & 0x3000 == 0x2000
}
fn normal(&self) -> bool {
self.rcnt & 0x8000 == 0
!self.gpio() && !self.multiplayer_format()
}
fn busy(&self) -> bool {
self.control & 0x80 != 0
@ -72,9 +84,21 @@ impl Serial {
}
let mut next = *self;
next.merge(address, bytes);
if !next.multiplayer_format()
&& (0..bytes.len())
.any(|offset| (SIOMULTI2..SIOCNT).contains(&(address + offset as u32)))
{
return Some((
address,
bytes[0],
"multiplayer receive registers outside multiplayer mode",
));
}
// Clearing start always permits cancellation/reconfiguration. Other
// live changes after a shifted bit lack an independently verified model.
if self.normal() && self.busy() && next.busy() {
// Check the written start bit before mode-specific read-only masking.
// A high-byte mode change must not silently cancel a clocked normal transfer.
if self.normal() && self.busy() && self.control_value(address, bytes) & 0x80 != 0 {
let changed_mode = self.normal() != next.normal();
let changed_clock = (self.control ^ next.control) & 0x1003 != 0;
let data_touched = (0..bytes.len()).any(|offset| {
@ -108,18 +132,33 @@ impl Serial {
if (SIODATA32..SIODATA32 + 4).contains(&address) {
return Some(self.data.to_le_bytes()[(address - SIODATA32) as usize]);
}
if (SIOMULTI2..SIOCNT).contains(&address) {
return self
.multiplayer_format()
.then(|| self.multi_extra.to_le_bytes()[(address - SIOMULTI2) as usize]);
}
let value = match address & !1 {
SIOCNT => {
let pins = if self.gpio() { self.gpio_pins() } else { 15 };
// SI is pulled high without a cable. Multiplayer drives SD high while idle.
// ID is a deterministic zero placeholder until a transfer (none can occur here).
self.control
| if self.normal() {
4
| (pins & 4)
| if self.multiplayer_format() {
(pins & 2) << 2
} else {
self.gpio_pins() & 4
0
}
}
SIODATA8 => {
if self.multiplayer_format() {
self.send
} else {
self.send & 255
}
}
SIODATA8 => u16::from(self.send),
RCNT => {
if self.normal() && address & 1 == 0 {
if !self.gpio() && address & 1 == 0 {
return None;
}
(self.rcnt & !15) | self.gpio_pins()
@ -130,16 +169,40 @@ impl Serial {
Some(value.to_le_bytes()[(address & 1) as usize])
}
fn control_value(&self, address: u32, bytes: &[u8]) -> u16 {
let mut control = self.control;
for (offset, &value) in bytes.iter().enumerate() {
let at = address + offset as u32;
if (SIOCNT..SIOCNT + 2).contains(&at) {
control = replace_byte(control, at, value);
}
}
control
}
fn merge(&mut self, address: u32, bytes: &[u8]) {
// Select the final format first: a word can change mode and write SEND together.
let control = self.control_value(address, bytes);
self.control = control
& if control & 0x3000 == 0x2000 {
0x6f03
} else {
0x508b
};
for (offset, &value) in bytes.iter().enumerate() {
let at = address + offset as u32;
if (SIODATA32..SIODATA32 + 4).contains(&at) {
let shift = (at - SIODATA32) * 8;
self.data = (self.data & !(255 << shift)) | (u32::from(value) << shift);
} else if (SIOMULTI2..SIOCNT).contains(&at) {
let shift = (at - SIOMULTI2) * 8;
self.multi_extra =
(self.multi_extra & !(255 << shift)) | (u32::from(value) << shift);
} else {
match at & !1 {
SIOCNT => self.control = replace_byte(self.control, at, value) & 0x508b,
SIODATA8 if at & 1 == 0 => self.send = value,
SIODATA8 if self.multiplayer_format() || at & 1 == 0 => {
self.send = replace_byte(self.send, at, value);
}
RCNT => self.rcnt = replace_byte(self.rcnt, at, value) & 0xc1ff,
_ => {}
}
@ -179,7 +242,7 @@ impl Serial {
if self.width() == 32 {
self.data = (self.data << 1) | 1;
} else {
self.send = (self.send << 1) | 1;
self.send = (self.send & 0xff00) | u16::from(((self.send as u8) << 1) | 1);
}
self.bits_left -= 1;
if self.bits_left == 0 {

View file

@ -11,6 +11,8 @@ mod iwram_bus;
#[cfg(test)]
mod iwram_history_tests;
#[cfg(test)]
mod multiplayer_step_tests;
#[cfg(test)]
mod prefetch_tests;
#[cfg(test)]
mod serial_step_tests;

View file

@ -0,0 +1,115 @@
//! Original multiplayer mode/alias checks at CPU bus phases and during preflight.
use super::{timing_event_tests::prepared, *};
use crate::{
io::{IF, SIOCNT, SIOMLT_SEND, SIOMULTI0, SIOMULTI2},
machine::Machine,
};
#[test]
fn mode_change_uses_normal_completion_phase_before_read_only_busy_masking() {
for elapsed in [56, 57] {
let (cpu, mut memory) = prepared(false, &[0xe5c10000], &[(0, 0x20), (1, SIOCNT + 1)]); // STRB r0,[r1]
memory.write16(SIOCNT, 0x4083).unwrap();
memory.advance_cycles(elapsed);
let serial = memory.io.serial;
let mut machine = Machine::new(cpu, memory);
let cpu = machine.cpu().clone();
if elapsed == 57 {
machine.step().unwrap(); // Normal completion, then multiplayer selection at cycle 64.
assert_eq!(machine.cycles(), 64);
assert_eq!(machine.memory().read16(SIOCNT).unwrap(), 0x200f);
assert_eq!(machine.memory().read16(IF).unwrap(), 0x80);
assert_eq!(machine.memory().io.serial.next_event_cycles(), None);
} else {
let error = machine.step().unwrap_err();
assert!(error.to_string().contains("serial reconfiguration"));
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.cycles(), 56);
assert_eq!(machine.memory().io.serial, serial);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
}
}
}
#[test]
fn arm_block_store_validates_receive_aliases_and_later_mode_before_committing_any_word() {
for unsupported in [false, true] {
let (cpu, mut memory) = prepared(
false,
&[0xe8a0000e],
&[
(0, SIOMULTI0),
(1, 0x12345678),
(2, 0xabcdef90),
(3, if unsupported { 0x98763000 } else { 0x98762000 }),
],
);
memory.write16(SIOCNT, 0x2000).unwrap();
let serial = memory.io.serial;
let mut machine = Machine::new(cpu, memory);
let cpu = machine.cpu().clone();
if unsupported {
let error = machine.step().unwrap_err();
assert!(error.to_string().contains("UART serial mode"));
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.memory().io.serial, serial);
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.cycles(), 0);
} else {
machine.step().unwrap();
assert_eq!(machine.cpu().registers()[0], SIOMULTI0 + 12);
assert_eq!(machine.memory().read32(SIOMULTI0).unwrap(), 0x12345678);
assert_eq!(machine.memory().read32(SIOMULTI2).unwrap(), 0xabcdef90);
assert_eq!(machine.memory().read16(SIOMLT_SEND).unwrap(), 0x9876);
assert_eq!(machine.memory().read16(SIOCNT).unwrap(), 0x200c);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
assert_eq!(machine.last_timing().data_cycles, 3);
}
}
}
#[test]
fn arm_block_load_observes_all_multiplayer_lanes_from_staged_registers() {
let (cpu, mut memory) = prepared(false, &[0xe8b0000e], &[(0, SIOMULTI0)]); // LDMIA r0!,{r1-r3}
memory.write32(SIOCNT, 0x98766083).unwrap();
memory.write32(SIOMULTI0, 0x12345678).unwrap();
memory.write32(SIOMULTI2, 0xabcdef90).unwrap();
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(
&machine.cpu().registers()[1..4],
&[0x12345678, 0xabcdef90, 0x9876600f]
);
assert_eq!(machine.cpu().registers()[0], SIOMULTI0 + 12);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
}
#[test]
fn shadow_mode_changes_control_later_access_validation_and_preserve_atomicity() {
let (_, mut memory) = prepared(false, &[0xe1a00000], &[]);
memory
.write_words(&[(SIOCNT, 0x2000), (SIOMULTI2, 0x12345678), (SIOCNT, 0)])
.unwrap();
assert_eq!(
memory.read32(SIOMULTI2),
Err(MemoryError::Unmapped(SIOMULTI2))
);
memory.write16(SIOCNT, 0x2000).unwrap();
assert_eq!(memory.read32(SIOMULTI2).unwrap(), 0x12345678);
let serial = memory.io.serial;
assert!(memory
.write_words(&[(0x02000000, 0xabcd), (SIOCNT, 0), (SIOMULTI2, 0)])
.is_err());
assert_eq!(memory.io.serial, serial);
assert_eq!(memory.read32(0x02000000).unwrap(), 0);
// A staged read cannot fall back to committed multiplayer data after leaving the mode.
memory.begin_timer_step();
memory.write16(SIOCNT, 0).unwrap();
assert_eq!(
memory.read32(SIOMULTI2),
Err(MemoryError::Unmapped(SIOMULTI2))
);
memory.discard_timer_step();
assert_eq!(memory.read32(SIOMULTI2).unwrap(), 0x12345678);
}

View file

@ -80,7 +80,7 @@ fn idle_normal_serial_data_and_control_have_distinct_widths() {
fn unsupported_operations_have_specific_retryable_diagnostics() {
let mut bus = memory();
for (address, value, description) in [
(SIOCNT + 1, 0x20, "multiplayer/UART"),
(SIOCNT + 1, 0x30, "UART serial mode"),
(RCNT + 1, 0xc0, "Joybus serial mode"),
(RCNT + 1, 0x81, "GPIO serial interrupt"),
(JOYCNT, 0x40, "Joybus interrupt"),
@ -95,7 +95,7 @@ fn unsupported_operations_have_specific_retryable_diagnostics() {
assert!(bus.write16(RCNT, 0xc0aa).is_err());
assert_eq!(bus.read16(RCNT).unwrap(), 0x805f); // Low byte was not committed.
bus.write16(SIOCNT, 0x4008).unwrap();
assert!(bus.write32(SIOCNT, 0xaabb2080).is_err());
assert!(bus.write32(SIOCNT, 0xaabb3080).is_err());
assert_eq!(bus.read16(SIOCNT).unwrap(), 0x400c);
assert_eq!(bus.read16(SIODATA8).unwrap(), 0);
}

View file

@ -146,7 +146,7 @@ fn arm_and_thumb_external_starts_complete_as_instructions_not_as_serial_transfer
assert_eq!(machine.memory().read16(IF).unwrap() & 0x80, 0);
} else {
let error = machine.step().unwrap_err();
assert!(error.to_string().contains("multiplayer/UART serial mode"));
assert!(error.to_string().contains("UART serial mode"));
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.cycles(), 0);
assert_eq!(machine.memory().read16(SIOCNT).unwrap(), 0x1007);
@ -161,7 +161,7 @@ fn invalid_word_and_block_writes_do_not_leave_a_partial_external_start() {
let mut bus = memory();
bus.write16(SIOCNT, 0x5000).unwrap();
bus.write16(SIODATA8, 0x12).unwrap();
assert!(bus.write32(SIOCNT, 0x00ab2080).is_err());
assert!(bus.write32(SIOCNT, 0x00ab3080).is_err());
assert_eq!(bus.read16(SIOCNT).unwrap(), 0x5004);
assert_eq!(bus.read16(SIODATA8).unwrap(), 0x12);
bus.write32(SIOCNT, 0x00ab5080).unwrap();
@ -188,7 +188,7 @@ fn dma_start_preserves_serial_wait_and_reports_only_dma_completion() {
bus.write32(DMA_BASE + 8, 0xc4000001).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
let error = machine.step().unwrap_err();
assert!(error.to_string().contains("multiplayer/UART serial mode"));
assert!(error.to_string().contains("UART serial mode"));
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.cycles(), 0);
assert_eq!(machine.memory().read16(SIOCNT).unwrap(), 4);

View file

@ -0,0 +1,207 @@
//! Original disconnected multiplayer register tests; no connected peer or completed transfer.
use gba_core::{
cpu::Cpu,
dma::DMA_BASE,
io::{
HALTCNT, IE, IF, IME, RCNT, SIOCNT, SIODATA32, SIODATA8, SIOMLT_SEND, SIOMULTI0, SIOMULTI1,
SIOMULTI2, SIOMULTI3,
},
machine::{Machine, StepKind},
memory::{Memory, MemoryError, ROM_START},
};
fn memory() -> Memory {
Memory::new(0xeaff_fffe_u32.to_le_bytes().to_vec()).unwrap()
}
#[test]
fn all_multiplayer_control_values_mask_status_and_cannot_start_a_disconnected_child() {
let mut bus = memory();
bus.write16(IE, 0x80).unwrap();
bus.write16(IME, 1).unwrap();
for value in 0..=u16::MAX {
if value & 0x3000 != 0x2000 {
continue;
}
bus.write16(SIOCNT, value).unwrap();
// SI high (child), SD high (local idle drive); no valid assigned ID yet.
assert_eq!(bus.read16(SIOCNT).unwrap(), (value & 0x6f03) | 12);
assert_eq!(bus.read8(SIOCNT).unwrap() & 0xf0, 0); // ID placeholder, no error or busy.
assert_eq!(bus.read16(IF).unwrap(), 0);
}
bus.advance_cycles(u32::MAX);
assert_eq!(bus.read16(IF).unwrap(), 0);
assert!(!bus.irq_pending());
assert_eq!(bus.read16(SIOCNT).unwrap(), 0x6f0f);
}
#[test]
fn receive_latches_have_independent_lanes_and_do_not_become_received_peer_data() {
let mut bus = memory();
bus.write16(SIOCNT, 0x2000).unwrap();
for address in [SIOMULTI0, SIOMULTI1, SIOMULTI2, SIOMULTI3] {
assert_eq!(bus.read16(address).unwrap(), 0); // Synthetic reset latches, not completed receives.
}
bus.write32(SIOMULTI0, 0x12345678).unwrap();
bus.write32(SIOMULTI2, 0x9abcdef0).unwrap();
bus.write8(SIOMULTI1 + 1, 0x55).unwrap();
bus.write16(SIOMULTI3, 0xaabb).unwrap();
assert_eq!(bus.read16(SIOMULTI0).unwrap(), 0x5678);
assert_eq!(bus.read16(SIOMULTI1).unwrap(), 0x5534);
assert_eq!(bus.read32(SIOMULTI2).unwrap(), 0xaabbdef0);
bus.write16(SIOMLT_SEND, 0xcdef).unwrap();
for rate in 0..4 {
bus.write16(SIOCNT, 0x6080 | rate).unwrap(); // Start is read-only for this disconnected child.
bus.advance_cycles(1000000);
assert_eq!(bus.read32(SIOMULTI0).unwrap(), 0x55345678);
assert_eq!(bus.read32(SIOMULTI2).unwrap(), 0xaabbdef0);
assert_eq!(bus.read16(SIOMLT_SEND).unwrap(), 0xcdef);
assert_eq!(bus.read16(IF).unwrap(), 0);
}
}
#[test]
fn send_width_and_receive_aliases_follow_the_selected_format() {
let mut bus = memory();
bus.write32(SIOCNT, 0xabcd2000).unwrap(); // Final multiplayer format applies to all SEND lanes.
assert_eq!(bus.read16(SIOMLT_SEND).unwrap(), 0xabcd);
bus.write8(SIOMLT_SEND, 0x12).unwrap();
bus.write8(SIOMLT_SEND + 1, 0x34).unwrap();
assert_eq!(bus.read16(SIOMLT_SEND).unwrap(), 0x3412);
bus.write32(SIOMULTI0, 0x12345678).unwrap();
bus.write32(SIOMULTI2, 0x9abcdef0).unwrap();
bus.write16(SIOCNT, 0x1000).unwrap();
assert_eq!(bus.read32(SIODATA32).unwrap(), 0x12345678);
assert_eq!(bus.read16(SIODATA8).unwrap(), 0x12);
assert_eq!(bus.read32(SIOMULTI2), Err(MemoryError::Unmapped(SIOMULTI2)));
assert!(bus
.write32(SIOMULTI2, 0)
.unwrap_err()
.to_string()
.contains("outside multiplayer mode"));
bus.write16(SIODATA8, 0xee56).unwrap(); // Existing normal mode ignores the upper write lane.
assert_eq!(bus.read16(SIODATA8).unwrap(), 0x56);
bus.write16(SIOCNT, 0x83).unwrap();
bus.advance_cycles(64); // Only the normal 8-bit register shifts.
bus.write16(SIOCNT, 0x2000).unwrap();
assert_eq!(bus.read16(SIOMLT_SEND).unwrap(), 0x34ff);
assert_eq!(bus.read32(SIOMULTI0).unwrap(), 0x12345678);
assert_eq!(bus.read32(SIOMULTI2).unwrap(), 0x9abcdef0);
}
#[test]
fn idle_multiplayer_neither_wakes_halt_nor_advances_stop() {
for stop in [false, true] {
let mut bus = memory();
bus.write16(IE, 0x80).unwrap();
bus.write16(IME, 1).unwrap();
bus.write16(SIOCNT, 0x6083).unwrap();
bus.write8(HALTCNT, if stop { 0x80 } else { 0 }).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
for _ in 0..100 {
assert_eq!(
machine.step().unwrap(),
if stop {
StepKind::StopIdle
} else {
StepKind::HaltIdle
}
);
}
assert_eq!(machine.halted(), !stop);
assert_eq!(machine.stopped(), stop);
assert_eq!(machine.cycles() == 0, stop);
assert_eq!(machine.memory().read16(SIOCNT).unwrap(), 0x600f);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
}
}
#[test]
fn gpio_status_uses_pin_latches_but_cannot_create_a_multiplayer_parent_transfer() {
let mut bus = memory();
bus.write16(RCNT, 0x80f0).unwrap(); // All GPIO outputs low.
bus.write16(SIOCNT, 0x2083).unwrap();
assert_eq!(bus.read16(SIOCNT).unwrap(), 0x2003);
bus.write16(RCNT, 0x80f6).unwrap(); // SI and SD high.
assert_eq!(bus.read16(SIOCNT).unwrap(), 0x200f);
bus.write16(RCNT, 0).unwrap();
assert_eq!(bus.read16(SIOCNT).unwrap(), 0x200f);
assert_eq!(bus.read8(RCNT), Err(MemoryError::Unmapped(RCNT))); // Do not guess SC pin samples.
bus.advance_cycles(1000000);
assert_eq!(bus.read16(IF).unwrap(), 0);
}
#[test]
fn active_normal_mode_cannot_be_silently_cancelled_by_read_only_multiplayer_busy_masking() {
let mut bus = memory();
bus.write16(SIOCNT, 0x4083).unwrap();
bus.advance_cycles(8);
for (address, value) in [(SIOCNT + 1, 0x20), (SIOCNT + 1, 0x60)] {
assert!(bus
.write8(address, value)
.unwrap_err()
.to_string()
.contains("serial reconfiguration"));
assert_eq!(bus.read16(SIOCNT).unwrap(), 0x4087);
assert_eq!(bus.read8(SIODATA8).unwrap(), 1);
}
assert!(bus.write16(SIOCNT, 0x2083).is_err());
bus.write16(SIOCNT, 0x2000).unwrap(); // Explicitly clear start and select multiplayer together.
bus.advance_cycles(1000000);
assert_eq!(bus.read16(IF).unwrap(), 0);
assert_eq!(bus.read16(SIOCNT).unwrap(), 0x200c);
}
#[test]
fn uart_rejection_preserves_multiplayer_control_and_send_lanes() {
let mut bus = memory();
bus.write32(SIOCNT, 0xabcd6001).unwrap();
let error = bus.write32(SIOCNT, 0x12343083).unwrap_err();
assert!(error.to_string().contains("UART serial mode"));
assert_eq!(bus.write32(SIOCNT, 0x12343083), Err(error));
assert_eq!(bus.read16(SIOCNT).unwrap(), 0x600d);
assert_eq!(bus.read16(SIOMLT_SEND).unwrap(), 0xabcd);
}
#[test]
fn original_arm_and_thumb_word_stores_select_multiplayer_and_write_full_send_data() {
for thumb in [false, true] {
let code = if thumb {
vec![
0xe59f0010, 0xe59f1010, 0xe28f2001, 0xe12fff12, 0xe7fe6001, 0xeafffffe, SIOCNT,
0xabcd6083,
]
} else {
vec![
0xe59f0008, 0xe59f1008, 0xe5801000, 0xeafffffe, SIOCNT, 0xabcd6083,
]
};
let mut bus = Memory::new(code.into_iter().flat_map(u32::to_le_bytes).collect()).unwrap();
let mut cpu = Cpu::new(ROM_START);
for _ in 0..if thumb { 4 } else { 2 } {
cpu.step(&mut bus).unwrap();
}
let mut machine = Machine::new(cpu, bus);
assert_eq!(machine.step().unwrap(), StepKind::Instruction);
assert_eq!(machine.memory().read16(SIOCNT).unwrap(), 0x600f);
assert_eq!(machine.memory().read16(SIOMLT_SEND).unwrap(), 0xabcd);
machine.memory_mut().advance_cycles(1000000);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
}
}
#[test]
fn dma_mode_and_send_write_only_requests_its_own_completion_irq() {
let mut bus = memory();
bus.write32(0x02000000, 0xabcd6083).unwrap();
bus.write32(DMA_BASE, 0x02000000).unwrap();
bus.write32(DMA_BASE + 4, SIOCNT).unwrap();
bus.write32(DMA_BASE + 8, 0xc4000001).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 0 });
assert_eq!(machine.memory().read16(SIOCNT).unwrap(), 0x600f);
assert_eq!(machine.memory().read16(SIOMLT_SEND).unwrap(), 0xabcd);
assert_eq!(machine.memory().read16(IF).unwrap(), 0x100);
machine.memory_mut().advance_cycles(1000000);
assert_eq!(machine.memory().read16(IF).unwrap(), 0x100);
}

View file

@ -65,11 +65,13 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/tests/pulse_sound/channel2.rs` | Channel 2 mapping, register gaps, independent state, shared clocks, and sum-before-rounding mixing |
| `crates/gba-core/tests/direct_sound.rs` | FIFO lanes/reset, signed mixing, timer selection, DMA refill, and HALT/STOP |
| `crates/gba-core/src/memory/audio_step_tests.rs` | Audio access phases, shadow validation, and CPU/DMA failure rollback |
| `crates/gba-core/src/io/serial.rs` | Disconnected normal serial shifter, internal clocks, external waiting, GPIO latches, and live-write validation |
| `crates/gba-core/src/io/serial.rs` | Disconnected normal serial shifter, internal clocks, external waiting, idle multiplayer registers, GPIO latches, and live-write validation |
| `crates/gba-core/tests/inactive_devices.rs` | Device masks, wave RAM, GPIO pull-ups, serial data, and failed CPU/DMA write isolation |
| `crates/gba-core/tests/serial_control.rs` | RCNT mode gating, inactive bits, byte/halfword lanes, CPU/DMA diagnostics, and interrupt isolation |
| `crates/gba-core/tests/serial_external.rs` | External-clock waiting, cancellation, HALT/STOP, ARM/Thumb/DMA starts, and atomic diagnostics |
| `crates/gba-core/tests/serial_internal.rs` | Nominal bit edges, widths/rates, cancellation, completion IRQs, HALT/STOP, and live-write limits |
| `crates/gba-core/tests/serial_multiplayer.rs` | Disconnected child status, receive/send lanes, mode aliases, ignored child start, HALT/STOP, and ARM/Thumb/DMA access |
| `crates/gba-core/src/memory/multiplayer_step_tests.rs` | Multiplayer mode changes at normal completion, block-transfer preflight, staged reads, and atomic diagnostics |
| `crates/gba-core/src/memory/serial_step_tests.rs` | Serial bus-phase observations, preflight validation, DMA, IF ordering, capture isolation, and rollback |
| `crates/gba-core/src/io/timer_step.rs` | Staged timer/audio/serial state, divider phase, timer/serial IF bits, and sound refill requests 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 |

View file

@ -39,7 +39,8 @@ Both modes call `bios::boot` with the loaded bytes.
The [original BIOS replacement](bios.md) executes its minimal boot before entering `0x08000000` in ARM System mode.
There is no command-line option for external BIOS files.
Unsupported services remain diagnostics. RegisterRamReset now accepts sound/serial reset flags, including `r0=0xff`, within the initialization subset.
Active sound and serial transfers still fail with device-specific diagnostics; accepting reset does not establish game compatibility.
Supported [sound](audio.md) and [serial](serial.md) behavior extends beyond reset, but remaining unsupported operations still produce diagnostics.
Accepting reset does not establish game compatibility.
## Terminal execution limits and results

View file

@ -1,8 +1,8 @@
# Disconnected serial subset
The core supports disconnected normal-mode transfers, external-clock waiting, and general-purpose pins.
The core supports disconnected normal-mode transfers, external-clock waiting, idle multiplayer configuration, and general-purpose pins.
Internal clocks shift pulled-high input at nominal GBA rates. No link partner is emulated.
External clock edges, multiplayer, UART, Joybus communication, and GPIO interrupts remain unsupported.
External clock edges, connected multiplayer transfers, UART, Joybus communication, and GPIO interrupts remain unsupported.
Unsupported activity remains diagnostic rather than reporting a fabricated completion.
## Registers
@ -13,13 +13,13 @@ Unsupported activity remains diagnostic rather than reporting a fabricated compl
- `SIOCNT` at `0x04000128` retains normal 8/32-bit mode, IRQ configuration, clock selection, and the output-data bit.
The retained mask is `0x508b`. The disconnected serial input is high, except when a general-purpose output drives it low.
Start bit 7 requests a transfer. Bit 0 selects external or internal clock; bit 1 selects the internal rate.
Completion or software cancellation clears start. Multiplayer/UART mode bit 13 remains unsupported.
Completion or software cancellation clears start. Multiplayer uses the separate format described below; UART remains diagnostic.
- `RCNT` at `0x04000134` retains mode bits, interrupt configuration, output latches, and pin directions with mask `0xc1ff`.
With bit 15 clear, bits 8 and 14 are writable latches without GPIO interrupt or Joybus effects.
With bit 15 set, bit 14 selects Joybus; otherwise bit 8 enables GPIO interrupts. Both active configurations remain diagnostic.
In general-purpose mode, each input reads high through its pull-up. Each output reads its output latch.
`RCNT=0x8000` therefore reads back as `0x800f`, not `0x8000`.
Normal-mode low-byte pin readback remains unmapped rather than guessed.
Normal and multiplayer low-byte pin readback remains unmapped rather than guessed.
- `JOYCNT` permits clearing already-empty status flags. Joybus interrupt enable is unsupported.
- `JOY_RECV` and `JOY_TRANS` permit only zero writes and zero readback for reset initialization.
Nonzero writes return an explicit error. Joybus status and data-transfer side effects are not implemented.
@ -95,6 +95,52 @@ The period arithmetic follows documented GBA rates. Activation phase, mid-transf
No external pin waveform, cable, connected partner, or serial-device protocol is implemented.
Original tests cover individual shifts, batching, cancellation, IRQ gating, HALT/STOP, bus phases, DMA, capture independence, and rollback.
## Disconnected multiplayer configuration
With RCNT bit 15 clear, SIOCNT mode bits 12–13 equal to `2` select multiplayer format.
The retained control mask is `0x6f03`: baud selection, unused writable bits 8–11, mode, and IRQ enable.
This subset models an idle unit without a cable, not a parent with missing players.
- SI reads high in status bit 2, identifying a child rather than a parent.
- SD reads high in bit 3 because the local idle output is high. This does not establish connected peers.
- ID bits 4–5 read a deterministic zero placeholder. Hardware does not define the ID before a completed transfer.
Zero must not be interpreted as an assigned parent role; SI determines that role.
- Error bit 6 and busy bit 7 remain clear. No parent supplies the clock that starts a child transfer.
- Start is read-only for a child. Writing it cannot start a transfer, reset receive data, or request an IRQ.
All baud settings retain this idle behavior. Elapsed cycles, DMA, and repeated start writes cannot create a linked transfer.
No multiplayer event can wake HALT. STOP retains the configuration without advancing clocks.
When GPIO is selected, SI/SD status follows the existing GPIO pin model; multiplayer transfers still cannot run.
RCNT low-byte samples remain unmapped outside GPIO mode. Floating clock-pin timing is not guessed.
### Multiplayer data registers
`SIOMLT_SEND` at `0x0400012a` is a 16-bit latch in multiplayer format.
It shares its low byte with normal-mode SIODATA8. Normal upper-byte reads return zero and writes are ignored.
Normal 8-bit shifts change only the low byte. The hidden upper byte survives mode changes in this bounded model.
A word write to SIOCNT selects the final format before applying both SEND lanes.
`SIOMULTI0` through `SIOMULTI3` occupy halfwords at `0x04000120`, `0x04000122`, `0x04000124`, and `0x04000126`.
The first two alias SIODATA32. The last two are accessible only with multiplayer format selected, including under GPIO.
Byte, halfword, and word accesses preserve independent lanes.
Receive latches start at zero under the synthetic reset policy and retain data across mode changes.
Selecting multiplayer or writing child start does not replace them with `0xffff`, local SEND data, or invented peer data.
Receive writes follow GBATEK's R/W register description. The pinned mGBA implementation instead leaves these writes unhandled.
Physical receive-write behavior, reset values, and hidden upper-byte retention have not been independently measured.
These are bounded register-model choices, not evidence of successful reception or full firmware-reset accuracy.
### Validation and limits
Mode changes use staged serial state at the bus-completion phase.
A high-byte mode write cannot silently cancel a busy normal transfer through multiplayer's read-only start mask.
Clear start explicitly, or wait for normal completion. A completion IRQ already requested remains pending after mode selection.
Block-store preflight observes earlier mode writes and rejects later invalid accesses without committing preceding writes.
Original tests cover control masks, all baud selections, register lanes, aliases, GPIO, HALT/STOP, and ARM/Thumb/DMA stores.
Bus-phase tests cover normal completion, retryable diagnostics, block loads/stores, and mode-dependent staged reads.
There is no parent, cable topology, external clock input, multiplayer timeout, or linked-game compatibility claim.
## BIOS reset
RegisterRamReset always clears the low halfword of SIODATA32, even without bit 5.
@ -102,6 +148,8 @@ The upper halfword remains unchanged in this functional subset.
With bit 5 selected, the firmware clears SIOCNT and SIODATA8, selects general-purpose inputs, and clears the supported Joybus reset registers.
It uses ordinary ARM bus stores, not a host-side reset bypass.
Clearing SIOCNT cancels a pending external-clock request. Unselected serial reset leaves that request pending.
The firmware writes SIODATA8 after selecting normal format. This does not clear the retained upper multiplayer SEND byte.
Full multiplayer reset side effects remain unverified; this increment does not extend the firmware reset sequence.
Original tests cover independent byte lanes, input pull-ups, output directions, reset flag selection, and explicit unsupported operations.
Whole-access and block-store validation prevent a rejected control value from committing preceding bytes or registers.
@ -113,8 +161,10 @@ Inactive configuration bits do not wake HALT. RCNT word accesses still reject th
- [GBATEK reset functions](https://problemkaputt.de/gbatek-bios-reset-functions.htm): reset flags, general-purpose selection, and the unconditional SIODATA32 side effect.
- [GBATEK normal serial mode](https://problemkaputt.de/gbatek-sio-normal-mode.htm): data widths, MSB-first shifting, rates, pulled-high SI, start/completion, IRQ selection, and external-clock waiting.
- [GBATEK multiplayer mode](https://problemkaputt.de/gbatek-sio-multi-player-mode.htm): child start restrictions, SI/SD status, undefined pre-transfer ID, register widths, and R/W receive registers.
- [LinkRawCable at c61bf351](https://github.com/afska/gba-link-connection/blob/c61bf351f68ad2d6e1c9d72d70e21bec19adfc0b/lib/LinkRawCable.hpp): public API usage for mode selection, SEND, receive words, and separate role/readiness/ID checks. Its disconnected-cable warning is not a pin-level measurement.
- [GBATEK mode summary](https://problemkaputt.de/gbatek-sio-control-registers-summary.htm): RCNT/SIOCNT mode selection.
- [mGBA serial implementation at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/sio.c): cross-checked normal transfer-duration arithmetic and completion IRQ handling. Its no-driver scheduled completion and zero receive data are not copied; they do not establish disconnected hardware behavior.
- [mGBA serial implementation at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/sio.c): cross-checked normal transfer-duration arithmetic and completion IRQ handling. Its no-driver scheduled completion and zero receive data are not copied; they do not establish disconnected hardware behavior. Multiplayer comparison covers SI/SD defaults, receive-write disagreement, and uncertain floating SC timing.
- [GBATEK](https://mgba-emu.github.io/gbatek/): GBA general-purpose pin directions, internal pull-ups, SI falling-edge interrupts, and the SIO mode-selection table.
- [mGBA BIOS implementation at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/bios.c): functional serial reset defaults, not copied source.

View file

@ -58,13 +58,19 @@ The serial model now handles that unshifted transition and clocks normal 8/32-bi
It shifts disconnected high input, clears start at completion, and requests serial IRQs through the ordinary device transaction.
No connected partner, fabricated peer data, or immediate completion was added.
Both runs now reach multiplayer mode selection:
Normal internal clocks then reached multiplayer selection (`SIOCNT=0x2000`, PC `0x082e4358`).
The disconnected multiplayer model now accepts that configuration and reports idle child status.
A child cannot start itself without a parent clock. No linked transfer, assigned player ID, or completion IRQ is fabricated.
The send register supports 16-bit data; receive latches do not become invented peer data.
See [the register model and evidence limits](../hardware/serial.md#disconnected-multiplayer-configuration).
Both terminal and native runs now reach a Game Pak GPIO control write:
```text
Steps: 955967; instructions: 953287; IRQ entries: 80; DMA units: 2600; HALT idle: 0
Nominal cycles: 3514542
PC=0x082e4358
unsupported multiplayer/UART serial mode: write 0x20 at 0x04000129
Steps: 956055; instructions: 953375; IRQ entries: 80; DMA units: 2600; HALT idle: 0
Nominal cycles: 3514932
PC=0x082e29f8
read-only memory at 0x080000c8
```
The native run captures twelve startup frames. These do not establish a working title screen or gameplay.
@ -79,10 +85,10 @@ Workspace debug/release tests, strict Clippy, public ARM/Thumb/memory/BIOS check
Native ROM-window tests and graphics smoke modes pass on Darwin arm64.
The pinned Pong debug/release reports match.
The next requirement is disconnected multiplayer-mode configuration and status, starting with SIOCNT mode value `0x2000`.
Research mode selection, absent-partner input levels, ready/error flags, and start behavior before extending the model.
Do not invent connected players or successful linked transfers to suppress the diagnostic.
The normal-mode result does not establish linked gameplay or GPIO interrupt support.
The next requirement is cartridge GPIO, starting with the control register at `0x080000c8`.
Research the Game Pak data/direction/control registers and real-time clock protocol before implementing them.
Keep ordinary ROM writes read-only. Do not hide the failure by ignoring every cartridge write.
This serial result does not establish linked gameplay or serial GPIO interrupt support.
Noise now has deterministic counter clocks and shares tested length/envelope logic with the independent pulse channels.
Sweep applies only to channel 1. Direct Sound has FIFO clocks and nominal DMA requests.

View file

@ -142,9 +142,11 @@ There is no host audio output or cartridge save support.
The [audio subset](hardware/audio.md) supports Direct Sound, both PSG pulse channels, noise channel 4, idle wave channel 3 state, and instantaneous digital mixing.
It does not yet provide a continuous sample stream. Wave channel 3 playback and unverified sound-DMA pairings remain diagnostic.
PSG timing and signed mixing are nominal; hardware startup edges and analog behavior remain unverified.
The [serial subset](hardware/serial.md) supports disconnected normal 8/32-bit transfers, external-clock waiting, and general-purpose pins.
The [serial subset](hardware/serial.md) supports disconnected normal 8/32-bit transfers, external-clock waiting, idle multiplayer configuration, and general-purpose pins.
Internal clocks shift high input at nominal rates, clear busy, and request completion IRQs. External requests retain busy without clock edges.
Connected links, multiplayer/UART, Joybus, GPIO interrupts, and unverified mid-transfer reconfiguration remain unsupported.
Disconnected multiplayer exposes child/ready status and 16-bit send/receive registers without inventing transfers or completion IRQs.
Pre-transfer ID is a zero placeholder; receive-write behavior and reset/alias details lack physical-hardware validation.
Connected links, multiplayer transfers, UART, Joybus, GPIO interrupts, and unverified mid-transfer reconfiguration remain unsupported.
RCNT retains inactive bits 8/14 in normal mode. Effective GPIO interrupt enable and Joybus selection remain diagnostic.
Mapped I/O covers DMA, timers, interrupts, WAITCNT, display control/status, background control/scroll/affine registers, window/effect registers, KEYINPUT/KEYCNT, POSTFLG, and HALTCNT.
Keypad IRQs use a documented polling model; hardware retrigger edges and asynchronous input timing remain unverified.
@ -158,7 +160,8 @@ A local Emerald run now completes its all-device reset and master sound enable.
It also passes the pulse/noise triggers and normal-mode RCNT configuration.
It also completes a CpuSet call to address zero through the corrected ignored BIOS-write behavior.
It also accepts an external-clock serial request without inventing clock edges or completion.
It now also completes internally clocked normal transfers and next stops at multiplayer mode selection (`0x04000129`, PC `0x082e4358`).
It now also completes internally clocked normal transfers and selects idle multiplayer mode.
It next stops at a Game Pak GPIO control write (`0x080000c8`, PC `0x082e29f8`). Cartridge GPIO remains unimplemented.
No title-screen, gameplay, or audible-game compatibility is established.
See [the startup result](research/emerald-reset.md) for the bounded run and next investigation.
See [cartridge loading](hardware/cartridge.md) for file limits, step budgets, and exit status.

View file

@ -211,7 +211,7 @@ They check errors for unsupported features and out-of-range fetches without chan
The CPU tile demo test verifies asset copies, every pixel, wrapping, opposite directions, reset, and VBlank synchronization.
RegisterRamReset tests check full memory regions and all memory-flag combinations from ARM and Thumb.
They verify forced blank, reserved flag bits, caller preservation, protected BIOS RAM, and selective serial/sound initialization.
All-device reset returns from ARM and Thumb; active sound and serial operations remain separately tested diagnostics.
All-device reset returns from ARM and Thumb; supported sound/serial activity and remaining diagnostics have separate regressions.
Rendering tests check write-only display state, both identity matrices, and the effect of zeroed sprite attributes.
Device tests check DMA address/count latches, stopped timer counters, cleared reloads, and IRQ acknowledgement.
Other checks cover partial clears, preserved input/device state, and callers whose own RAM code is erased.
@ -295,7 +295,10 @@ Timed and untimed execution are compared across all 65,536 Thumb encodings with
Normal serial tests cover both widths and internal rates, every bit edge, disconnected high input, batching, and completion IRQs.
They check external-clock handoff, cancellation, phase retention, HALT bounds, STOP freeze/wake, and explicit live-write limits.
Bus-phase tests cover start/load timing, IF acknowledgement order, CPU/DMA rollback, block preflight, and capture independence.
These tests validate the documented nominal serial model, not physical link-pin timing or multiplayer compatibility.
Disconnected multiplayer tests cover every control value in that format, all baud settings, send/receive lanes, and normal-mode aliases.
They verify idle child status, ignored start, no completion IRQ or HALT wake, GPIO sampling, and ARM/Thumb/DMA stores.
Staged tests check mode changes at normal completion, block loads/stores, rollback, and reads after leaving multiplayer format.
These tests validate the documented bounded serial model, not physical pin timing, receive-write behavior, or linked-game compatibility.
Timer/IRQ tests verify that changing WAITCNT changes when an interrupt is sampled.
Timer tests cover all prescalers, reload changes, start/stop behavior, byte writes, large clock advances, and cascades.
Ordered timer tests cover ARM/Thumb sampling, coherent byte lanes, block-transfer phases, reload/control ordering, and prescaler retention.

View file

@ -40,9 +40,10 @@ The native Darwin window also starts and presents frames without a diagnostic.
This exposed and corrected missing firmware affine-scale initialization, without a game-specific patch.
Frame-based scenarios and the preparation adapter now make the result repeatable.
A local Emerald run now passes its initial all-device reset, master sound enable, and pulse/noise triggers.
It stops at GPIO serial interrupt control. The core has tested Direct Sound, both pulse channels, and noise channel 4.
It also passes normal serial transfers and idle multiplayer configuration, then stops at Game Pak GPIO control.
The core has tested Direct Sound, both pulse channels, and noise channel 4.
Wave channel 3, a continuous sample stream, and desktop audio output remain missing.
See [the runtime result](research/emerald-reset.md). Serial control-register semantics are the next demonstrated startup requirement.
See [the runtime result](research/emerald-reset.md). Cartridge GPIO and its real-time clock protocol are the next demonstrated startup investigation.
The separate save requirement remains cartridge SRAM, followed by safe host save persistence.
SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols.