feat(audio): capture transactional fixed-rate stereo samples

This commit is contained in:
0xrsydn 2026-10-07 07:16:42 +07:00
commit 902c892d3f
11 changed files with 382 additions and 15 deletions

View file

@ -1,5 +1,7 @@
//! Nominal Direct Sound and PSG channels, including single-bank wave playback. No host output. //! Nominal Direct Sound and PSG channels, including single-bank wave playback. No host output.
pub(crate) mod capture;
pub use capture::{AUDIO_QUEUE_CAPACITY, AUDIO_SAMPLE_RATE};
mod modulation; mod modulation;
mod noise; mod noise;
mod pulse; mod pulse;

View file

@ -0,0 +1,103 @@
//! Optional fixed-rate digital capture, separate from hardware channel state.
use super::StereoLevel;
use std::collections::VecDeque;
/// Nominal stereo capture rate. This is not the GBA PWM/resolution model.
pub const AUDIO_SAMPLE_RATE: u32 = 32768;
/// Maximum committed stereo frames retained before the caller drains them.
pub const AUDIO_QUEUE_CAPACITY: usize = 4096;
const PERIOD: u32 = 512;
pub(crate) const STEP_SAMPLES: usize = 8;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SampleClock(Option<u16>);
impl SampleClock {
/// Advance devices at every retained sample boundary. Once capacity is
/// exhausted, advance the remainder in bulk and count discarded samples.
pub(crate) fn advance(
&mut self,
mut cycles: u32,
mut capacity: usize,
mut advance: impl FnMut(u32, bool),
) -> u64 {
let Some(mut phase) = self.0 else {
advance(cycles, false);
return 0;
};
while cycles != 0 && capacity != 0 {
let count = cycles.min(PERIOD - u32::from(phase));
phase += count as u16;
cycles -= count;
let sample = u32::from(phase) == PERIOD;
if sample {
phase = 0;
capacity -= 1;
}
advance(count, sample);
}
let discarded = (u64::from(phase) + u64::from(cycles)) / u64::from(PERIOD);
if cycles != 0 {
advance(cycles, false);
}
self.0 = Some(((u64::from(phase) + u64::from(cycles)) % u64::from(PERIOD)) as u16);
discarded
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SampleBatch {
pub(crate) samples: [StereoLevel; STEP_SAMPLES],
pub(crate) len: usize,
pub(crate) dropped: u64,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct AudioCapture {
pub(crate) clock: SampleClock,
queue: VecDeque<StereoLevel>,
pub(crate) dropped: u64,
}
impl AudioCapture {
pub(crate) fn set_enabled(&mut self, enabled: bool) {
if enabled == self.clock.0.is_some() {
return;
}
self.clock = SampleClock(enabled.then_some(0));
self.queue = if enabled {
VecDeque::with_capacity(AUDIO_QUEUE_CAPACITY)
} else {
VecDeque::new()
};
self.dropped = 0;
}
pub(crate) fn capacity(&self) -> usize {
AUDIO_QUEUE_CAPACITY - self.queue.len()
}
pub(crate) fn push(&mut self, sample: StereoLevel) {
if self.queue.len() < AUDIO_QUEUE_CAPACITY {
self.queue.push_back(sample);
} else {
self.dropped = self.dropped.saturating_add(1);
}
}
pub(crate) fn commit(&mut self, clock: SampleClock, batch: SampleBatch) {
self.clock = clock;
self.dropped = self.dropped.saturating_add(batch.dropped);
for sample in &batch.samples[..batch.len] {
self.push(*sample);
}
}
pub(crate) fn drain(&mut self, output: &mut [StereoLevel]) -> usize {
let count = output.len().min(self.queue.len());
for slot in &mut output[..count] {
*slot = self.queue.pop_front().unwrap();
}
count
}
}

View file

@ -161,6 +161,7 @@ enum PowerState {
pub(crate) struct Io { pub(crate) struct Io {
pub(crate) serial: Serial, pub(crate) serial: Serial,
pub(crate) audio: Audio, pub(crate) audio: Audio,
pub(crate) audio_capture: crate::audio::capture::AudioCapture,
timers: [Timer; 4], timers: [Timer; 4],
// Low ten system-clock bits cover every supported divider. STOP freezes this phase. // Low ten system-clock bits cover every supported divider. STOP freezes this phase.
timer_phase: u16, timer_phase: u16,
@ -430,6 +431,7 @@ impl Io {
pub(crate) fn commit_timer_step(&mut self, step: TimerStep) { pub(crate) fn commit_timer_step(&mut self, step: TimerStep) {
self.audio = step.audio; self.audio = step.audio;
self.audio_capture.commit(step.sample_clock, step.samples);
self.serial = step.serial; self.serial = step.serial;
self.dma.trigger_sound(self.audio.take_requests()); self.dma.trigger_sound(self.audio.take_requests());
self.timers = step.timers; self.timers = step.timers;
@ -493,12 +495,20 @@ impl Io {
self.pending |= self.display.advance(cycles); self.pending |= self.display.advance(cycles);
if advance_timers { if advance_timers {
self.pending |= self.serial.advance(cycles); self.pending |= self.serial.advance(cycles);
let mut clock = self.audio_capture.clock;
let dropped = clock.advance(cycles, self.audio_capture.capacity(), |count, sample| {
self.pending |= Self::advance_timer_bank( self.pending |= Self::advance_timer_bank(
&mut self.timers, &mut self.timers,
&mut self.timer_phase, &mut self.timer_phase,
&mut self.audio, &mut self.audio,
cycles, count,
); );
if sample {
self.audio_capture.push(self.audio.level());
}
});
self.audio_capture.clock = clock;
self.audio_capture.dropped = self.audio_capture.dropped.saturating_add(dropped);
self.dma.trigger_sound(self.audio.take_requests()); self.dma.trigger_sound(self.audio.take_requests());
} }
self.wake_if_requested(); self.wake_if_requested();

View file

@ -7,6 +7,8 @@ pub(super) const STAGED_IRQ_MASK: u16 = 0xf8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TimerStep { pub(crate) struct TimerStep {
pub(crate) audio: crate::audio::Audio, pub(crate) audio: crate::audio::Audio,
pub(crate) sample_clock: crate::audio::capture::SampleClock,
pub(crate) samples: crate::audio::capture::SampleBatch,
pub(crate) serial: super::Serial, pub(crate) serial: super::Serial,
pub(super) timers: [Timer; 4], pub(super) timers: [Timer; 4],
pub(super) phase: u16, pub(super) phase: u16,
@ -18,6 +20,8 @@ impl TimerStep {
pub(super) fn new(io: &Io) -> Self { pub(super) fn new(io: &Io) -> Self {
Self { Self {
audio: io.audio, audio: io.audio,
sample_clock: io.audio_capture.clock,
samples: Default::default(),
serial: io.serial, serial: io.serial,
timers: io.timers, timers: io.timers,
phase: io.timer_phase, phase: io.timer_phase,
@ -29,12 +33,23 @@ impl TimerStep {
pub(crate) fn advance_to(&mut self, elapsed: u32) { pub(crate) fn advance_to(&mut self, elapsed: u32) {
assert!(elapsed >= self.elapsed, "timer step time must be monotonic"); assert!(elapsed >= self.elapsed, "timer step time must be monotonic");
self.pending |= self.serial.advance(elapsed - self.elapsed); self.pending |= self.serial.advance(elapsed - self.elapsed);
let dropped = self.sample_clock.advance(
elapsed - self.elapsed,
crate::audio::capture::STEP_SAMPLES - self.samples.len,
|count, sample| {
self.pending |= Io::advance_timer_bank( self.pending |= Io::advance_timer_bank(
&mut self.timers, &mut self.timers,
&mut self.phase, &mut self.phase,
&mut self.audio, &mut self.audio,
elapsed - self.elapsed, count,
); );
if sample {
self.samples.samples[self.samples.len] = self.audio.level();
self.samples.len += 1;
}
},
);
self.samples.dropped = self.samples.dropped.saturating_add(dropped);
self.elapsed = elapsed; self.elapsed = elapsed;
} }

View file

@ -77,7 +77,7 @@ impl Error for FrameRunError {
} }
/// Owns the CPU and memory bus. Use this instead of Cpu::step to advance device /// Owns the CPU and memory bus. Use this instead of Cpu::step to advance device
/// clocks, execute DMA, honor HALT/STOP, and deliver interrupts. No audio yet. /// clocks, execute DMA, honor HALT/STOP, and deliver interrupts. Host audio is external.
pub struct Machine { pub struct Machine {
cpu: Cpu, cpu: Cpu,
memory: Memory, memory: Memory,

View file

@ -1,5 +1,7 @@
use std::{cell::Cell, error::Error, fmt}; use std::{cell::Cell, error::Error, fmt};
#[cfg(test)]
mod audio_capture_tests;
#[cfg(test)] #[cfg(test)]
mod audio_step_tests; mod audio_step_tests;
#[cfg(test)] #[cfg(test)]
@ -279,6 +281,28 @@ impl Memory {
self.io.audio.level() self.io.audio.level()
} }
/// Enable optional 32768 Hz stereo capture. A toggle clears queued frames,
/// resets its synthetic phase/drop counter, and does not reset sound hardware.
/// Call between machine steps. Repeating the current setting preserves state.
pub fn set_audio_capture(&mut self, enabled: bool) {
assert!(
self.timer_step.get().is_none(),
"audio capture configuration during a step"
);
self.io.audio_capture.set_enabled(enabled);
}
/// Drain committed frames, oldest first. Failed steps never publish samples.
/// Each sample is a signed ten-bit digital level, not normalized host PCM.
pub fn drain_audio_samples(&mut self, output: &mut [StereoLevel]) -> usize {
self.io.audio_capture.drain(output)
}
/// Frames discarded because the bounded capture buffers were full.
pub fn audio_dropped_samples(&self) -> u64 {
self.io.audio_capture.dropped
}
fn serial_state(&self) -> Serial { fn serial_state(&self) -> Serial {
self.timer_step self.timer_step
.get() .get()

View file

@ -0,0 +1,69 @@
use super::{timing_event_tests::prepared, *};
use crate::{
audio::StereoLevel,
io::{SOUNDBIAS, SOUNDCNT_X},
machine::Machine,
};
#[test]
fn samples_before_store_completion_keep_old_level_and_publish_only_once() {
let (cpu, mut memory) = prepared(false, &[0xe5810000], &[(0, 0x202), (1, SOUNDBIAS)]);
memory.write16(SOUNDCNT_X, 0x80).unwrap();
memory.write16(SOUNDBIAS, 0x200).unwrap();
memory.set_audio_capture(true);
memory.advance_cycles(505);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap(); // Sample at cycle 512, then bias write at same bus phase.
let mut output = [StereoLevel::default(); 4];
assert_eq!(machine.memory_mut().drain_audio_samples(&mut output), 1);
assert_eq!(output[0].left, 0);
assert_eq!(machine.memory().audio_level().left, 2);
machine.memory_mut().advance_cycles(512);
assert_eq!(machine.memory_mut().drain_audio_samples(&mut output), 1);
assert_eq!(output[0].left, 2);
}
#[test]
fn failed_cpu_and_dma_steps_discard_speculative_samples_and_phase() {
for dma in [false, true] {
let (cpu, mut memory) = prepared(false, &[0xe8910005], &[(1, SOUNDBIAS)]); // LDM crosses unmapped 8c.
memory.set_audio_capture(true);
memory.advance_cycles(508);
if dma {
memory.write32(DMA_BASE, 0x02000000).unwrap();
memory.write32(DMA_BASE + 4, 0x0400008c).unwrap();
memory.write32(DMA_BASE + 8, 0x84000001).unwrap();
}
let before = memory.io.audio_capture.clone();
let mut machine = Machine::new(cpu, memory);
for _ in 0..2 {
assert!(machine.step().is_err());
assert_eq!(machine.memory().io.audio_capture, before);
assert_eq!(machine.cycles(), 508);
}
machine.memory_mut().advance_cycles(4);
assert_eq!(
machine
.memory_mut()
.drain_audio_samples(&mut [StereoLevel::default(); 2]),
1
);
}
}
#[test]
fn preflight_cannot_publish_samples_or_advance_the_sample_clock() {
let (_, mut memory) = prepared(false, &[0xe1a00000], &[]);
memory.set_audio_capture(true);
memory.advance_cycles(511);
let before = memory.io.audio_capture.clone();
assert!(memory
.write_words(&[(SOUNDBIAS, 0x202), (0x0400008c, 0)])
.is_err());
assert_eq!(memory.io.audio_capture, before);
memory.begin_timer_step();
memory.advance_timer_step(512);
assert_eq!(memory.io.audio_capture, before);
memory.discard_timer_step();
assert_eq!(memory.io.audio_capture, before);
}

View file

@ -0,0 +1,113 @@
//! Original fixed-rate sample probes; no host device or external recordings.
use gba_core::{
audio::{StereoLevel, AUDIO_QUEUE_CAPACITY, AUDIO_SAMPLE_RATE},
io::{FIFO_A, HALTCNT, SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, TIMER_BASE},
memory::Memory,
};
fn bus() -> Memory {
let mut bus = Memory::new(vec![]).unwrap();
bus.write16(SOUNDCNT_X, 0x80).unwrap();
bus.write16(SOUNDBIAS, 0x200).unwrap();
bus.write16(SOUNDCNT_H, 0x0304).unwrap();
bus
}
fn drain(bus: &mut Memory) -> Vec<StereoLevel> {
let mut output = vec![StereoLevel::default(); AUDIO_QUEUE_CAPACITY];
let count = bus.drain_audio_samples(&mut output);
output.truncate(count);
output
}
#[test]
fn exact_rate_phase_toggle_and_drain_order() {
assert_eq!(AUDIO_SAMPLE_RATE * 512, 16777216);
let mut bus = bus();
bus.advance_cycles(100);
assert!(drain(&mut bus).is_empty());
bus.set_audio_capture(true);
bus.advance_cycles(511);
bus.set_audio_capture(true); // Idempotent; no phase reset.
assert!(drain(&mut bus).is_empty());
bus.advance_cycles(1);
bus.write16(SOUNDBIAS, 0x202).unwrap();
bus.advance_cycles(512);
let mut one = [StereoLevel::default(); 1];
assert_eq!(bus.drain_audio_samples(&mut one), 1);
assert_eq!(one[0].left, 0);
assert_eq!(bus.drain_audio_samples(&mut one), 1);
assert_eq!(one[0].left, 2);
assert_eq!(bus.drain_audio_samples(&mut one), 0);
bus.advance_cycles(511);
bus.set_audio_capture(false);
bus.set_audio_capture(true);
bus.advance_cycles(1);
assert!(drain(&mut bus).is_empty());
}
#[test]
fn timer_overflow_at_sample_boundary_precedes_sample_and_later_stores() {
let mut bus = bus();
bus.write32(FIFO_A, 0x04030201).unwrap();
bus.write32(TIMER_BASE, 0x0080fe00).unwrap(); // 512-clock timer.
bus.set_audio_capture(true);
bus.advance_cycles(4 * 512);
assert_eq!(
drain(&mut bus).iter().map(|s| s.left).collect::<Vec<_>>(),
[4, 8, 12, 16]
);
bus.advance_cycles(512);
assert_eq!(drain(&mut bus)[0].left, 0);
}
#[test]
fn psg_sample_clock_crosses_many_oscillator_edges_and_respects_halt_stop() {
let mut bus = bus();
bus.write16(SOUNDCNT_H, 2).unwrap();
bus.write16(0x04000080, 0x1177).unwrap();
bus.write16(0x04000062, 0xf080).unwrap();
bus.write16(0x04000064, 0x87e0).unwrap(); // One duty step every 512 cycles.
bus.set_audio_capture(true);
bus.write8(HALTCNT, 0).unwrap();
bus.advance_cycles(8 * 512);
assert_eq!(
drain(&mut bus).iter().map(|s| s.left).collect::<Vec<_>>(),
[-120, -120, -120, -120, 120, 120, 120, 120]
);
bus.write8(HALTCNT, 0x80).unwrap();
bus.advance_cycles(8192);
assert!(drain(&mut bus).is_empty());
}
#[test]
fn bulk_split_capture_and_no_capture_keep_hardware_state_and_samples_equivalent() {
let mut a = bus();
let mut b = bus();
let mut c = bus();
for bus in [&mut a, &mut b, &mut c] {
bus.write32(FIFO_A, 0x807f01ff).unwrap();
bus.write32(TIMER_BASE, 0x0080ff9c).unwrap();
}
a.set_audio_capture(true);
b.set_audio_capture(true);
b.set_scanline_rendering(true);
a.advance_cycles(100003);
c.advance_cycles(100003);
for _ in 0..100003 {
b.advance_cycles(1);
}
assert_eq!(drain(&mut a), drain(&mut b));
assert_eq!(a.audio_level(), c.audio_level());
assert_eq!(a.read32(TIMER_BASE), c.read32(TIMER_BASE));
assert_eq!(a.audio_dropped_samples(), 0);
}
#[test]
fn bounded_overflow_counts_drops_and_keeps_earliest_frames_without_clock_stall() {
let mut bus = bus();
bus.set_audio_capture(true);
bus.advance_cycles(u32::MAX);
assert_eq!(
bus.audio_dropped_samples(),
u64::from(u32::MAX) / 512 - AUDIO_QUEUE_CAPACITY as u64
);
assert_eq!(drain(&mut bus).len(), AUDIO_QUEUE_CAPACITY);
bus.advance_cycles(1);
assert_eq!(drain(&mut bus).len(), 1); // Overflow did not lose the phase.
bus.set_audio_capture(false);
assert_eq!(bus.audio_dropped_samples(), 0);
}

View file

@ -65,6 +65,9 @@ The repository is a Cargo workspace with three crates:
| `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/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.rs` | I/O registers, timers, HALT/STOP wake-up, next-event bounds, and IRQ latches |
| `crates/gba-core/src/audio.rs` | Direct Sound state, PSG sequencer, wave banks, and instantaneous stereo mixing | | `crates/gba-core/src/audio.rs` | Direct Sound state, PSG sequencer, wave banks, and instantaneous stereo mixing |
| `crates/gba-core/src/audio/capture.rs` | Optional fixed-rate sample clock, bounded committed queue, transaction batches, and drop accounting |
| `crates/gba-core/tests/audio_capture.rs` | Sample rate/order, timer and pulse samples, HALT/STOP, batching, and bounded overflow |
| `crates/gba-core/src/memory/audio_capture_tests.rs` | Sample/store bus ordering, speculative capture, failed-step rollback, and preflight isolation |
| `crates/gba-core/src/audio/pulse.rs` | Shared pulse oscillator; sweep is mapped only for channel 1 | | `crates/gba-core/src/audio/pulse.rs` | Shared pulse oscillator; sweep is mapped only for channel 1 |
| `crates/gba-core/src/audio/modulation.rs` | Shared PSG length, envelope, logical DAC gate, and activity state | | `crates/gba-core/src/audio/modulation.rs` | Shared PSG length, envelope, logical DAC gate, and activity state |
| `crates/gba-core/src/audio/wave.rs` | Single-bank wave samples, rotating RAM, length/gate state, and fractional gain | | `crates/gba-core/src/audio/wave.rs` | Single-bank wave samples, rotating RAM, length/gate state, and fractional gain |

View file

@ -226,8 +226,8 @@ This centered mixer follows GBATEK's signed range and the NanoBoyAdvance compari
The mixer adds all supported channels and the ten-bit bias, clips each side to `0..1023`, and subtracts 512. The mixer adds all supported channels and the ten-bit bias, clips each side to `0..1023`, and subtracts 512.
`StereoLevel` therefore contains signed levels in `-512..511`. Master disable returns zero. `StereoLevel` therefore contains signed levels in `-512..511`. Master disable returns zero.
Bias resolution bits retain their values but do not yet drive pulse-width modulation (PWM) sampling or quantization. Bias resolution bits retain their values but do not yet drive pulse-width modulation (PWM) sampling or quantization.
There is no resampling, analog filtering, sample queue, or host output backend. The optional capture stream below samples this mixer. Hardware PWM sampling and analog filtering remain unimplemented.
Polling once per video frame will not reconstruct audio. Polling `audio_level()` once per video frame will not reconstruct audio.
Timer and sound state share the staged CPU/DMA clock. Timer and sound state share the staged CPU/DMA clock.
Reads and writes observe their nominal bus-completion phase, after earlier timer overflows. Reads and writes observe their nominal bus-completion phase, after earlier timer overflows.
@ -235,6 +235,33 @@ Successful steps commit once. Failed steps discard FIFO consumption, held sample
Block-store validation simulates sound enable/disable in a temporary state before committing any RAM or I/O writes. Block-store validation simulates sound enable/disable in a temporary state before committing any RAM or I/O writes.
Other devices retain their existing scheduling limits. Other devices retain their existing scheduling limits.
## Fixed-rate digital capture
`Memory::set_audio_capture(true)` enables optional stereo capture at `AUDIO_SAMPLE_RATE` (32,768 Hz).
The first sample occurs after 512 system clocks. Later samples use the same interval, independent of video frames.
This is a nominal digital stream, not the hardware PWM/resolution model selected by SOUNDBIAS.
Each sample observes PSG advancement and timer-driven Direct Sound consumption through its clock boundary.
When a CPU write completes on that same boundary, sampling precedes the write under the existing bus-phase ordering.
No host time, window code, device dependency, resampling, or file operation enters the core.
Master sound disable produces silence while capture continues. HALT continues capture; STOP freezes its phase and produces no frames.
`drain_audio_samples` copies committed stereo frames, oldest first, into a caller-provided slice.
Values use the same signed ten-bit scale as `StereoLevel`, not normalized host PCM.
CPU/DMA steps stage samples alongside timer/audio state. A failed step discards its samples and sample-clock changes.
Block-store preflight never publishes samples. Row capture does not clock sound a second time.
CPU-only stepping retains its existing no-device-clock behavior.
The committed queue holds at most `AUDIO_QUEUE_CAPACITY` (4,096) stereo frames.
A transaction stages at most eight frames. Ordinary instruction/DMA timing fits within this budget.
Full buffers discard new frames, retain older frames, and increment `audio_dropped_samples` without blocking emulation.
Large device-only clock advances bound work by available capacity, then advance remaining hardware state in bulk.
Draining often avoids loss; callers must inspect the drop counter rather than assuming lossless capture.
Capture is disabled by default. Changing enable state clears queued frames, phase, and the drop counter, but does not reset audio hardware.
Repeating the current enable state preserves capture state. Configure capture between machine steps.
Original tests cover exact sample boundaries, timer consumption, pulse phases, batching, queue overflow, HALT/STOP, and transactional rollback.
## Validation and remaining work ## Validation and remaining work
Original tests cover signed samples, byte order, queue capacity, partial writes, resets, underflow, stereo routing, and clipping. Original tests cover signed samples, byte order, queue capacity, partial writes, resets, underflow, stereo routing, and clipping.
@ -251,7 +278,7 @@ CPU/DMA tests include noise register writes at bus completion and failed-step ro
BIOS reset tests verify that selected sound reset stops all supported PSG channels, while unselected sound state remains active. BIOS reset tests verify that selected sound reset stops all supported PSG channels, while unselected sound state remains active.
These tests validate the documented nominal model, not hardware audio fidelity. These tests validate the documented nominal model, not hardware audio fidelity.
Remaining audio work: two-bank wave playback, verified live bank changes, a continuous output stream, PWM/mixer sampling, and a Darwin host backend. Remaining audio work: two-bank wave playback, verified live bank changes, hardware PWM sampling, filtering/resampling, and a Darwin host backend.
A local Emerald input probe exposed a wave trigger with the gate disabled. This is now accepted without activating playback. A local Emerald input probe exposed a wave trigger with the gate disabled. This is now accepted without activating playback.
The same scheduled-input probe passes that point and completes its extended frame budget; audible output and gameplay remain unverified. The same scheduled-input probe passes that point and completes its extended frame budget; audible output and gameplay remain unverified.
See [the local runtime result](../research/emerald-reset.md). See [the local runtime result](../research/emerald-reset.md).

View file

@ -153,7 +153,8 @@ The optional [cartridge GPIO/RTC subset](hardware/cartridge-gpio.md) supports ca
RTC persistence, interrupts, invalid-date correction, physical subsecond timing, GPIO aliases, and byte writes remain unsupported or unverified. RTC persistence, interrupts, invalid-date correction, physical subsecond timing, GPIO aliases, and byte writes remain unsupported or unverified.
The core uses deterministic caller time. Desktop RTC runs use host UTC/elapsed time and are not fully deterministic. The core uses deterministic caller time. Desktop RTC runs use host UTC/elapsed time and are not fully deterministic.
The [audio subset](hardware/audio.md) supports Direct Sound, both PSG pulse channels, single-bank wave channel 3, noise channel 4, and instantaneous digital mixing. The [audio subset](hardware/audio.md) supports Direct Sound, both PSG pulse channels, single-bank wave channel 3, noise channel 4, and instantaneous digital mixing.
It does not yet provide a continuous sample stream. Two-bank wave playback, active wave-bank changes, and unverified sound-DMA pairings remain diagnostic. Optional 32,768 Hz digital capture provides bounded, transactional stereo samples with explicit drop counts.
Two-bank wave playback, active wave-bank changes, and unverified sound-DMA pairings remain diagnostic.
PSG timing and signed mixing are nominal; hardware startup edges and analog behavior remain unverified. 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, idle multiplayer configuration, 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. Internal clocks shift high input at nominal rates, clear busy, and request completion IRQs. External requests retain busy without clock edges.