fix(serial): gate RCNT interrupt and Joybus controls by mode

This commit is contained in:
0xrsydn 2026-10-07 03:10:04 +07:00
commit 86f8663904
7 changed files with 203 additions and 20 deletions

View file

@ -28,12 +28,14 @@ impl Inactive {
/// Value-dependent rejection is independent of device state, so batch
/// stores can validate all values before committing any RAM/I/O writes.
/// RCNT mode and IRQ-enable bits share the high byte. Each high-byte write
/// supplies the complete selection, independent of the previous low byte.
pub(super) fn unsupported(address: u32, value: u8) -> Option<&'static str> {
match address {
SIOCNT if value & 0x80 != 0 => Some("serial transfer start"),
0x0400_0129 if value & 0x20 != 0 => Some("multiplayer/UART serial mode"),
0x0400_0135 if value & 0x40 != 0 => Some("Joybus serial mode"),
0x0400_0135 if value & 1 != 0 => Some("GPIO serial interrupt enable"),
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"),
JOY_RECV..=0x0400_0157 if value != 0 => Some("Joybus data access beyond reset"),
_ => None,
@ -83,7 +85,7 @@ impl Inactive {
self.serial_control = replace_byte(self.serial_control, address, value) & 0x500b
}
SIODATA8 if address & 1 == 0 => self.serial_send = value,
RCNT => self.rcnt = replace_byte(self.rcnt, address, value) & 0x80ff,
RCNT => self.rcnt = replace_byte(self.rcnt, address, value) & 0xc1ff,
_ => {} // Unused/read-only bits have no effect.
}
}

View file

@ -82,8 +82,8 @@ fn unsupported_operations_have_specific_retryable_diagnostics() {
for (address, value, description) in [
(SIOCNT, 0x80, "serial transfer start"),
(SIOCNT + 1, 0x20, "multiplayer/UART"),
(RCNT + 1, 0x40, "Joybus serial mode"),
(RCNT + 1, 1, "GPIO serial interrupt"),
(RCNT + 1, 0xc0, "Joybus serial mode"),
(RCNT + 1, 0x81, "GPIO serial interrupt"),
(JOYCNT, 0x40, "Joybus interrupt"),
(JOY_RECV, 1, "Joybus data access"),
] {

View file

@ -0,0 +1,167 @@
//! Original mode-gating regressions. No link partner or transfer completion is modeled.
use gba_core::{
cpu::Cpu,
dma::DMA_BASE,
io::{HALTCNT, IE, IF, IME, RCNT, SIOCNT},
machine::{Machine, StepKind},
memory::{Memory, MemoryError, ROM_START},
};
fn memory() -> Memory {
Memory::new(0xeaff_fffe_u32.to_le_bytes().to_vec()).unwrap()
}
#[test]
fn rcnt_high_byte_selection_is_independent_of_previous_mode_and_low_byte() {
let mut bus = memory();
for previous in [0x8000, 0x80f5, 0x0100, 0x4100] {
for low in [0, 0x55, 0xff] {
for high in 0..=u8::MAX {
for halfword in [false, true] {
bus.write16(RCNT, previous).unwrap();
let before = (bus.read8(RCNT), bus.read8(RCNT + 1));
let result = if halfword {
bus.write16(RCNT, u16::from_le_bytes([low, high]))
} else {
bus.write8(RCNT + 1, high)
};
// Decode individual mode bits, separately from the implementation masks.
let gpio_or_joybus = high & 0x80 != 0;
let joybus = gpio_or_joybus && high & 0x40 != 0;
let gpio_irq = gpio_or_joybus && high & 1 != 0;
if joybus || gpio_irq {
assert_eq!(
result,
Err(MemoryError::UnsupportedIo {
address: RCNT + 1,
value: high,
operation: if joybus {
"Joybus serial mode"
} else {
"GPIO serial interrupt enable"
},
})
);
assert_eq!((bus.read8(RCNT), bus.read8(RCNT + 1)), before);
} else {
result.unwrap();
assert_eq!(bus.read8(RCNT + 1).unwrap(), high & 0xc1);
if gpio_or_joybus {
let latch = if halfword { low } else { previous as u8 };
let outputs = latch >> 4;
let pins = (latch & outputs | !outputs) & 15;
assert_eq!(bus.read8(RCNT).unwrap(), (latch & 0xf0) | pins);
} else {
assert!(bus.read8(RCNT).is_err());
}
}
assert_eq!(bus.read16(IF).unwrap() & 0x80, 0);
}
}
}
}
}
#[test]
fn inactive_irq_and_joybus_bits_retain_latches_without_interrupts_or_halt_wake() {
let mut bus = memory();
bus.write16(IE, 0x80).unwrap();
bus.write16(IME, 1).unwrap();
bus.write16(SIOCNT, 0x5008).unwrap();
bus.write16(RCNT, 0x80f5).unwrap(); // SI output high.
bus.write8(HALTCNT, 0).unwrap();
for high in [1, 0x41, 0x40, 0] {
bus.write8(RCNT + 1, high).unwrap();
bus.write8(RCNT, 0xf1).unwrap(); // SI latch low, but not in GPIO mode.
bus.advance_cycles(1000000);
assert_eq!(bus.read8(RCNT + 1).unwrap(), high);
assert_eq!(bus.read16(SIOCNT).unwrap(), 0x500c); // Serial input remains pulled high.
assert_eq!(bus.read16(IF).unwrap() & 0x80, 0);
assert!(bus.halted());
}
bus.write8(RCNT + 1, 0x80).unwrap(); // GPIO, IRQ disabled; lower latches survived.
assert_eq!(bus.read16(RCNT).unwrap(), 0x80f1);
assert_eq!(bus.read16(SIOCNT).unwrap(), 0x5008); // GPIO SI output now drives low.
assert_eq!(bus.read16(IF).unwrap() & 0x80, 0);
assert!(bus.halted());
}
fn prepared_store(instruction: u32, address: u32, value: u32) -> Machine {
let code = [
0xe59f_0008, // LDR r0, destination
0xe59f_1008, // LDR r1, value
instruction,
0xeaff_fffe,
address,
value,
];
let mut bus = Memory::new(code.into_iter().flat_map(u32::to_le_bytes).collect()).unwrap();
bus.write16(RCNT, 0x80f5).unwrap();
let mut cpu = Cpu::new(ROM_START);
cpu.step(&mut bus).unwrap();
cpu.step(&mut bus).unwrap();
Machine::new(cpu, bus)
}
#[test]
fn cpu_byte_and_halfword_writes_accept_inactive_bits_but_reject_active_modes_atomically() {
for (instruction, address, shift) in [(0xe5c0_1000, RCNT + 1, 0), (0xe1c0_10b0, RCNT, 8)] {
for high in [1, 0x41, 0x81, 0xc0, 0xc1] {
let mut machine = prepared_store(instruction, address, high << shift);
let cpu = machine.cpu().clone();
if high & 0x80 == 0 {
assert_eq!(machine.step().unwrap(), StepKind::Instruction);
assert_eq!(machine.memory().read8(RCNT + 1).unwrap(), high as u8);
assert!(machine.cycles() > 0);
} else {
let error = machine.step().unwrap_err();
assert!(error.to_string().contains(if high & 0x40 != 0 {
"Joybus serial mode"
} else {
"GPIO serial interrupt enable"
}));
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.cycles(), 0);
assert_eq!(machine.memory().read16(RCNT).unwrap(), 0x80f5);
assert_eq!(machine.step(), Err(error));
}
assert_eq!(machine.memory().read16(IF).unwrap() & 0x80, 0);
}
}
}
#[test]
fn dma_halfword_rejection_is_retryable_then_inactive_configuration_completes() {
for high in [0x81, 0xc0, 0xc1] {
let mut bus = memory();
bus.write16(RCNT, 0x80f5).unwrap();
bus.write16(0x02000000, high << 8).unwrap();
bus.write32(DMA_BASE, 0x02000000).unwrap();
bus.write32(DMA_BASE + 4, RCNT).unwrap();
bus.write32(DMA_BASE + 8, 0x80000001).unwrap(); // One halfword, immediate.
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
let cpu = machine.cpu().clone();
let error = machine.step().unwrap_err();
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.cycles(), 0);
assert_eq!(machine.memory().read16(RCNT).unwrap(), 0x80f5);
assert_eq!(machine.memory().read16(DMA_BASE + 10).unwrap(), 0x8000);
machine.memory_mut().write16(0x02000000, 0x4100).unwrap();
machine.step().unwrap();
assert_eq!(machine.memory().read8(RCNT + 1).unwrap(), 0x41);
assert_eq!(machine.memory().read16(DMA_BASE + 10).unwrap() & 0x8000, 0);
assert_eq!(machine.memory().read16(IF).unwrap() & 0x80, 0);
}
}
#[test]
fn rcnt_word_padding_remains_unmapped_without_partial_writes() {
let mut bus = memory();
bus.write16(RCNT, 0x80f5).unwrap();
assert_eq!(
bus.write32(RCNT, 0x4100),
Err(MemoryError::Unmapped(RCNT + 2))
);
assert_eq!(bus.read16(RCNT).unwrap(), 0x80f5);
}

View file

@ -66,6 +66,7 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/src/memory/audio_step_tests.rs` | Audio access phases, shadow validation, and CPU/DMA failure rollback |
| `crates/gba-core/src/io/inactive.rs` | Disconnected serial initialization with explicit unsupported-activity 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/src/io/timer_step.rs` | Staged timer/audio state, divider phase, timer 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 |
| `crates/gba-core/src/display.rs` | Display clock, scanline status, comparison edges, and display IRQ events |

View file

@ -11,10 +11,12 @@ 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 `0x500b`. The disconnected serial input is high, except when a general-purpose output drives it low.
Transfer-start bit 7 and multiplayer/UART mode bit 13 return explicit errors.
- `RCNT` at `0x04000134` retains general-purpose mode, output latches, and pin directions with mask `0x80ff`.
- `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`.
Joybus mode and GPIO interrupt enable return errors. Normal-mode low-byte pin readback remains unmapped rather than guessed.
Normal-mode 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.
@ -32,12 +34,15 @@ It uses ordinary ARM bus stores, not a host-side reset bypass.
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.
RCNT selection bits share the high byte. Each high-byte write replaces the complete selection, so validation needs no retained-mode shadow.
Mode-gating tests cover every high-byte value, previous modes, low-byte preservation, CPU stores, DMA retries, and unchanged interrupt state.
Inactive configuration bits do not wake HALT. RCNT word accesses still reject the unmapped padding at `0x04000136`.
## References
- [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, input status, and control bits.
- [GBATEK](https://mgba-emu.github.io/gbatek/): general-purpose pin directions and internal pull-ups.
- [GBATEK normal serial mode](https://problemkaputt.de/gbatek-sio-normal-mode.htm): data widths, input status, control bits, and writable unused RCNT bits 8/14.
- [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.
Exact firmware write ordering and hardware pin timing remain unverified.

View file

@ -33,13 +33,18 @@ The earlier reset-only implementation stopped at master sound enable (`0x0400008
The Direct Sound increment then reached a PSG channel 1 trigger (`0x04000065`, PC `0x082e03bc`).
Channel 1 support reached the channel 2 trigger (`0x0400006d`, PC `0x082e03c0`).
Both pulse channels then reached the noise trigger (`0x0400007d`, PC `0x082e03c4`).
With noise channel 4 implemented, both runs now stop at general-purpose input/output (GPIO) serial interrupt enable:
Noise channel 4 support then reached an incorrectly rejected RCNT write: `0x0100` at `0x04000134`, PC `0x082e2aac`.
RCNT bit 15 was clear, so bit 8 did not enable general-purpose input/output (GPIO) interrupts.
GBATEK documents bits 8 and 14 as writable but unused in normal mode.
The serial model now retains these inactive bits. Effective GPIO interrupt enable and Joybus selection still return explicit errors.
Both runs pass that write and now stop in the original BIOS replacement:
```text
Steps: 395420; instructions: 393690; IRQ entries: 2; DMA units: 1728; HALT idle: 0
Nominal cycles: 1328057
PC=0x082e2aac
unsupported GPIO serial interrupt enable: write 0x01 at 0x04000135
Steps: 398019; instructions: 396289; IRQ entries: 2; DMA units: 1728; HALT idle: 0
Nominal cycles: 1334271
PC=0x00000378
read-only memory at 0x00000000
```
The native run captures five startup frames. These do not establish a working title screen or gameplay.
@ -54,9 +59,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 observed requirement is [serial control-register behavior](../hardware/serial.md), specifically RCNT bit 8.
Check mode gating and interrupt-enable semantics before changing the current rejection.
This diagnostic alone does not establish that the game needs an active link transfer.
The next investigation is the BIOS replacement's attempted write to address zero at PC `0x00000378`.
Trace the service arguments and original firmware routine before deciding whether the caller or service behavior is wrong.
The diagnostic alone does not establish the root cause. Do not make BIOS memory writable to bypass it.
The corrected RCNT write does not establish a need for active link transfers or GPIO interrupts.
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,6 +142,7 @@ The [audio subset](hardware/audio.md) supports Direct Sound, both PSG pulse chan
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 initialization subset](hardware/serial.md) supports idle normal data and disconnected general-purpose pins, not active transfers.
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.
STOP implements keypad wake only. Serial/Game Pak wake sources, oscillator restart delays, and exact wake timing remain unimplemented or unverified.
@ -151,9 +152,10 @@ ROM files can run in bounded terminal mode or a window with the original BIOS re
ROM windows have no demo-specific startup checks. Input is sampled between bounded execution slices.
Audio, saves, and external BIOS loading remain unavailable. ROM loading does not establish commercial-game compatibility.
A local Emerald run now completes its all-device reset and master sound enable.
It also passes the pulse and noise triggers, then stops explicitly at GPIO serial interrupt enable (`0x04000135`).
No title-screen, gameplay, or audible-game compatibility is established.
Its captured startup frames do not establish title-screen or gameplay compatibility. See [the startup result](research/emerald-reset.md).
It also passes the pulse/noise triggers and normal-mode RCNT configuration.
It next stops on an attempted write to address zero in the BIOS replacement at PC `0x00000378`.
The cause remains under investigation. 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.
The [ROM suite runner](rom-tests.md) has original regressions and a [pinned public ARM baseline](public-arm-tests.md).
The pinned public ARM ROM passes its result checkpoint in debug and release builds.