From 86f86639046ca652ad12703db767a855c16f9138 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Wed, 7 Oct 2026 03:10:04 +0700 Subject: [PATCH] fix(serial): gate RCNT interrupt and Joybus controls by mode --- crates/gba-core/src/io/inactive.rs | 8 +- crates/gba-core/tests/inactive_devices.rs | 4 +- crates/gba-core/tests/serial_control.rs | 167 ++++++++++++++++++++++ docs/architecture.md | 1 + docs/hardware/serial.md | 13 +- docs/research/emerald-reset.md | 22 +-- docs/status.md | 8 +- 7 files changed, 203 insertions(+), 20 deletions(-) create mode 100644 crates/gba-core/tests/serial_control.rs diff --git a/crates/gba-core/src/io/inactive.rs b/crates/gba-core/src/io/inactive.rs index 145c053..d011def 100644 --- a/crates/gba-core/src/io/inactive.rs +++ b/crates/gba-core/src/io/inactive.rs @@ -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. } } diff --git a/crates/gba-core/tests/inactive_devices.rs b/crates/gba-core/tests/inactive_devices.rs index 6930374..c515e12 100644 --- a/crates/gba-core/tests/inactive_devices.rs +++ b/crates/gba-core/tests/inactive_devices.rs @@ -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"), ] { diff --git a/crates/gba-core/tests/serial_control.rs b/crates/gba-core/tests/serial_control.rs new file mode 100644 index 0000000..8f2d94e --- /dev/null +++ b/crates/gba-core/tests/serial_control.rs @@ -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); +} diff --git a/docs/architecture.md b/docs/architecture.md index f0d3722..9ac1394 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/hardware/serial.md b/docs/hardware/serial.md index 20631e6..d0f2b59 100644 --- a/docs/hardware/serial.md +++ b/docs/hardware/serial.md @@ -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. diff --git a/docs/research/emerald-reset.md b/docs/research/emerald-reset.md index fd2c10f..863f760 100644 --- a/docs/research/emerald-reset.md +++ b/docs/research/emerald-reset.md @@ -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. diff --git a/docs/status.md b/docs/status.md index 829c03d..9c4a52d 100644 --- a/docs/status.md +++ b/docs/status.md @@ -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.