Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_cdstream/
console.rs

1//! The console side: the real drive, the one global [`Engine`], and the
2//! exception wrapper that feeds it.
3//!
4//! Everything here is for the target. (It also compiles under `cfg(doc)` so
5//! the documentation shows it; none of it may run on the host.)
6//!
7//! # Interrupt path
8//!
9//! The wrapper sits in the general exception vector in front of psx-rt's
10//! handler. For an interrupt with the CD source pending it saves the
11//! interrupted CPU context, switches to a private 2 KiB stack, calls the
12//! engine for one sector or one response, restores the context, and jumps to
13//! psx-rt's handler either way, so the VBlank counter, the display flip and
14//! the fault handling run exactly as before. Anything else goes straight
15//! through. The wrapper never touches the interrupted stack, which is what
16//! makes it safe with a scratchpad stack; [`install`] declares it so
17//! (`psx_rt::interrupts::declare_stack_safe_handler`).
18//!
19//! # Foreground path
20//!
21//! Every foreground call closes the CD source in `I_MASK` first, so the
22//! handler cannot run while the engine is being touched, and opens it again
23//! afterwards if the engine wants it open.
24
25use crate::engine::{Config, Engine, LeaseState, Owner, Phase, StreamStats};
26use crate::hw::CdHw;
27use crate::request::{Request, RequestState, SubmitError, Ticket};
28use core::cell::UnsafeCell;
29use psx_hw::cd::{index_status, irq as flag};
30use psx_hw::irq::source;
31use psx_io::irq;
32use psx_io::periph::Cd;
33use psx_io::timers::{self, Timer};
34
35/// CD interrupt source bit in `I_STAT` and `I_MASK`.
36const CD_BIT: u32 = 1 << source::CDROM;
37/// Status reads to wait for room in the parameter FIFO.
38const PARAM_POLLS: u32 = 32;
39/// Status reads to wait for the data FIFO to fill after a sector interrupt.
40const FIFO_POLLS: u32 = 256;
41/// Bytes of private stack the handler runs on.
42pub const HANDLER_STACK_BYTES: usize = 2048;
43/// Fill byte of the handler stack, to measure how much of it was used.
44const STACK_FILL: u8 = 0xa5;
45
46// -------------------------------------------------------------------- drive
47
48/// The CD-ROM controller, through the SDK's register steps.
49pub struct Mmio {
50    time_handler: bool,
51}
52
53impl Mmio {
54    const fn new() -> Self {
55        Mmio {
56            time_handler: false,
57        }
58    }
59}
60
61/// The controller token. The transport owns the controller whenever the
62/// engine runs, which is what [`install`] took the real token for.
63fn token() -> Cd {
64    // SAFETY: a token is a logic guard (see `psx_io::periph`). `install`
65    // holds the real one for as long as the engine may touch the
66    // controller, and hands it back only when the engine is detached or
67    // leased out.
68    unsafe { Cd::steal() }
69}
70
71impl CdHw for Mmio {
72    const TRACE: bool = cfg!(feature = "trace");
73
74    fn issue(&mut self, command: u8, params: &[u8]) -> bool {
75        let mut cd = token();
76        cd.set_irq_enable_mask(0);
77        cd.acknowledge_all_and_reset_parameters();
78        cd.discard_response();
79        cd.reset_parameter_fifo();
80        for &param in params {
81            if !cd.wait_parameter_room(PARAM_POLLS) {
82                return false;
83            }
84            cd.send_parameter_byte(param);
85        }
86        cd.send_command_byte(command);
87        cd.set_irq_enable_mask(flag::ALL);
88        true
89    }
90
91    fn interrupt_code(&mut self) -> u8 {
92        token().irq_flag_value()
93    }
94
95    fn error_response(&mut self) -> (u8, u8) {
96        let mut cd = token();
97        let status = cd.read_response_byte();
98        (status, cd.read_response_byte())
99    }
100
101    fn discard_response(&mut self) {
102        token().discard_response();
103    }
104
105    fn acknowledge(&mut self, bits: u8) {
106        token().acknowledge_irq(bits);
107    }
108
109    fn silence_output(&mut self) {
110        token().set_irq_enable_mask(0);
111    }
112
113    fn drop_data_request(&mut self) {
114        token().clear_data_request();
115    }
116
117    unsafe fn pop_sector(&mut self, destination: *mut u32, store_words: usize) -> bool {
118        let mut cd = token();
119        cd.request_data();
120        let mut ready = false;
121        for _ in 0..FIFO_POLLS {
122            if cd.status_register() & index_status::DATA_FIFO_NOT_EMPTY != 0 {
123                ready = true;
124                break;
125            }
126        }
127        if !ready {
128            return false;
129        }
130        for word in 0..crate::SECTOR_WORDS as usize {
131            let b0 = u32::from(cd.read_data_byte());
132            let b1 = u32::from(cd.read_data_byte());
133            let b2 = u32::from(cd.read_data_byte());
134            let b3 = u32::from(cd.read_data_byte());
135            if word < store_words {
136                // SAFETY: the caller guarantees `destination` is valid for
137                // `store_words` word writes (`CdHw::pop_sector`'s contract).
138                unsafe {
139                    destination
140                        .add(word)
141                        .write_volatile(b0 | b1 << 8 | b2 << 16 | b3 << 24);
142                }
143            }
144        }
145        true
146    }
147
148    fn set_source_enabled(&mut self, enabled: bool) {
149        // Only the mask bit: a flag that arrived while the source was closed
150        // must still be pending when it opens, or its sector is lost. (An
151        // acknowledge of the CPU-side bit here dropped sectors in the first
152        // emulator run, because the foreground closes the source around
153        // every call.) Stale bits are cleared by `attach` and by the
154        // preamble of every command.
155        let mask = irq::mask();
156        if enabled {
157            irq::set_mask(mask | CD_BIT);
158        } else {
159            irq::set_mask(mask & !CD_BIT);
160        }
161    }
162
163    fn vblank_count(&mut self) -> u32 {
164        psx_rt::interrupts::vblank_count()
165    }
166
167    fn clock(&mut self) -> u16 {
168        if self.time_handler {
169            timers::counter(Timer::Timer2)
170        } else {
171            0
172        }
173    }
174
175    #[cfg(feature = "trace")]
176    fn trace(&mut self, word: u32) {
177        // SAFETY: the ring is written only here, from the handler or from
178        // a foreground section with the CD source closed, so the two never
179        // overlap; readers (tools) only look.
180        unsafe {
181            let n = core::ptr::read_volatile(&raw const PSX_CD_TRACE_N);
182            core::ptr::write_volatile(&raw mut PSX_CD_TRACE_N, n.wrapping_add(1));
183            let slot = (&raw mut PSX_CD_TRACE)
184                .cast::<u32>()
185                .add((n & 127) as usize);
186            core::ptr::write_volatile(slot, word);
187        }
188    }
189}
190
191// ------------------------------------------------------------------- state
192
193struct Global(UnsafeCell<Engine<Mmio>>);
194
195// SAFETY: the engine is reached from one CPU only, from the foreground with
196// the CD source closed or from the interrupt handler, never both at once.
197unsafe impl Sync for Global {}
198
199static ENGINE: Global = Global(UnsafeCell::new(Engine::new(Mmio::new(), Config::DEFAULT)));
200
201/// The controller token while the transport holds it.
202struct TokenSlot(UnsafeCell<Option<Cd>>);
203
204// SAFETY: touched from the foreground only, and only inside `with_engine`
205// sections or before the handler can run, never from the handler.
206unsafe impl Sync for TokenSlot {}
207
208impl TokenSlot {
209    fn is_held(&self) -> bool {
210        // SAFETY: foreground-only access, one thread of control (see `Sync`).
211        unsafe { (*self.0.get()).is_some() }
212    }
213
214    fn put(&self, cd: Cd) {
215        // SAFETY: as `is_held`.
216        unsafe { *self.0.get() = Some(cd) };
217    }
218
219    fn take(&self) -> Option<Cd> {
220        // SAFETY: as `is_held`.
221        unsafe { (*self.0.get()).take() }
222    }
223}
224
225static TOKEN: TokenSlot = TokenSlot(UnsafeCell::new(None));
226
227#[repr(C, align(16))]
228struct Stack([u8; HANDLER_STACK_BYTES]);
229
230/// The handler's private stack, painted with `0xa5` by [`install`] so a tool
231/// can see how much of it a run used ([`handler_stack_unused_bytes`] does the
232/// same from inside).
233#[no_mangle]
234static mut PSX_CD_IRQ_STACK: Stack = Stack([0; HANDLER_STACK_BYTES]);
235/// Where the wrapper saves the interrupted context.
236#[no_mangle]
237static mut PSX_CD_IRQ_CONTEXT: [u32; 34] = [0; 34];
238
239/// The counters, as one block for tools that read guest memory:
240/// [`StreamStats`] with its field order. Updated after every interrupt and
241/// every foreground call.
242#[no_mangle]
243pub static mut PSX_CD_STATS: StreamStats = StreamStats {
244    irq_count: 0,
245    sectors: 0,
246    discarded_sectors: 0,
247    max_irq_ticks: 0,
248    phase: 0,
249    error: 0,
250    requests_done: 0,
251    requests_cancelled: 0,
252    requests_failed: 0,
253    chained: 0,
254    resumed: 0,
255    rejected: 0,
256};
257
258/// The last 128 controller events (feature `trace`):
259/// `kind << 24 | phase << 16 | (flag or command) << 8 | received & 0xff`.
260#[cfg(feature = "trace")]
261#[no_mangle]
262pub static mut PSX_CD_TRACE: [u32; 128] = [0; 128];
263/// Events recorded so far (feature `trace`); the next slot is `n & 127`.
264#[cfg(feature = "trace")]
265#[no_mangle]
266pub static mut PSX_CD_TRACE_N: u32 = 0;
267
268/// Copy the counters into `PSX_CD_STATS`. Out of line: every wrapper and the
269/// handler end with this, and inlined it was a dozen loads and stores in each.
270#[inline(never)]
271fn publish(engine: &Engine<Mmio>) {
272    // SAFETY: a plain store of a `repr(C)` block that only this module
273    // writes; tools read it from outside the program.
274    unsafe { core::ptr::write_volatile(&raw mut PSX_CD_STATS, engine.stats()) }
275}
276
277/// Start of a foreground section: close the CD source before the engine
278/// reference exists, so the handler cannot be running (or start) while this
279/// one is live. Out of line so each wrapper carries a call, not the sequence.
280#[inline(never)]
281fn enter() -> &'static mut Engine<Mmio> {
282    let mask = irq::mask();
283    irq::set_mask(mask & !CD_BIT);
284    // SAFETY: the source is closed, so the handler (the only other user of
285    // the engine) cannot run until `leave` reopens it, and no other
286    // foreground reference exists because the CPU has one thread of control.
287    let engine = unsafe { &mut *ENGINE.0.get() };
288    engine.note_source_closed();
289    engine
290}
291
292/// End of a foreground section: reopen the source if the engine wants it and
293/// publish the counters.
294#[inline(never)]
295fn leave(engine: &mut Engine<Mmio>) {
296    engine.sync_source();
297    publish(engine);
298}
299
300/// Run `f` on the engine with the CD source closed, then publish the
301/// counters and reopen the source if the engine wants it.
302///
303/// The section's entry and exit are calls, which the compiler cannot move
304/// the engine accesses across (both touch `I_MASK` through volatile stores).
305#[inline(always)]
306fn with_engine<R>(f: impl FnOnce(&mut Engine<Mmio>) -> R) -> R {
307    let engine = enter();
308    let result = f(engine);
309    leave(engine);
310    result
311}
312
313// -------------------------------------------------------------- the wrapper
314
315#[cfg(target_arch = "mips")]
316core::arch::global_asm!(
317    r#"
318    .set noreorder
319    .set noat
320    .section .text.psx_cdstream_irq
321    .globl psx_cdstream_exception_wrapper
322psx_cdstream_exception_wrapper:
323    # Not an interrupt: straight through.
324    mfc0  $26, $13
325    nop
326    andi  $26, $26, 0x007c
327    bnez  $26, 9f
328    nop
329    # An interrupt, but is the CD source one of the pending, enabled ones?
330    lui   $26, {io_hi}
331    lw    $27, {i_stat}($26)
332    lw    $26, {i_mask}($26)
333    nop
334    and   $27, $27, $26
335    andi  $27, $27, {cd_bit}
336    beqz  $27, 9f
337    nop
338    # Save the interrupted context (everything but $k0/$k1) in our own block.
339    lui   $26, %hi({context})
340    addiu $26, $26, %lo({context})
341    sw    $1, 0($26)
342    sw    $2, 4($26)
343    sw    $3, 8($26)
344    sw    $4, 12($26)
345    sw    $5, 16($26)
346    sw    $6, 20($26)
347    sw    $7, 24($26)
348    sw    $8, 28($26)
349    sw    $9, 32($26)
350    sw    $10, 36($26)
351    sw    $11, 40($26)
352    sw    $12, 44($26)
353    sw    $13, 48($26)
354    sw    $14, 52($26)
355    sw    $15, 56($26)
356    sw    $16, 60($26)
357    sw    $17, 64($26)
358    sw    $18, 68($26)
359    sw    $19, 72($26)
360    sw    $20, 76($26)
361    sw    $21, 80($26)
362    sw    $22, 84($26)
363    sw    $23, 88($26)
364    sw    $24, 92($26)
365    sw    $25, 96($26)
366    sw    $28, 100($26)
367    sw    $29, 104($26)
368    sw    $30, 108($26)
369    sw    $31, 112($26)
370    mfhi  $27
371    sw    $27, 116($26)
372    mflo  $27
373    sw    $27, 120($26)
374    # Our own stack; the interrupted one is never written.
375    lui   $29, %hi({stack}+{stack_bytes})
376    addiu $29, $29, %lo({stack}+{stack_bytes})
377    addiu $29, $29, -16
378    jal   {handler}
379    nop
380    lui   $26, %hi({context})
381    addiu $26, $26, %lo({context})
382    lw    $27, 116($26)
383    nop
384    mthi  $27
385    lw    $27, 120($26)
386    nop
387    mtlo  $27
388    lw    $1, 0($26)
389    lw    $2, 4($26)
390    lw    $3, 8($26)
391    lw    $4, 12($26)
392    lw    $5, 16($26)
393    lw    $6, 20($26)
394    lw    $7, 24($26)
395    lw    $8, 28($26)
396    lw    $9, 32($26)
397    lw    $10, 36($26)
398    lw    $11, 40($26)
399    lw    $12, 44($26)
400    lw    $13, 48($26)
401    lw    $14, 52($26)
402    lw    $15, 56($26)
403    lw    $16, 60($26)
404    lw    $17, 64($26)
405    lw    $18, 68($26)
406    lw    $19, 72($26)
407    lw    $20, 76($26)
408    lw    $21, 80($26)
409    lw    $22, 84($26)
410    lw    $23, 88($26)
411    lw    $24, 92($26)
412    lw    $25, 96($26)
413    lw    $28, 100($26)
414    lw    $29, 104($26)
415    lw    $30, 108($26)
416    lw    $31, 112($26)
417    nop
4189:
419    # psx-rt's handler: VBlank, display flip, faults, strays, and the return.
420    j     __psx_rt_exception_handler
421    nop
422    .set at
423    .set reorder
424    "#,
425    io_hi = const psx_hw::memory::io::BASE >> 16,
426    i_stat = const psx_hw::irq::I_STAT & 0xFFFF,
427    i_mask = const psx_hw::irq::I_MASK & 0xFFFF,
428    cd_bit = const CD_BIT,
429    stack = sym PSX_CD_IRQ_STACK,
430    stack_bytes = const HANDLER_STACK_BYTES,
431    context = sym PSX_CD_IRQ_CONTEXT,
432    handler = sym psx_cdstream_interrupt,
433);
434
435// The wrapper reaches both registers with one `lui` of the I/O window and a
436// signed 16-bit offset.
437#[cfg(target_arch = "mips")]
438const _: () = {
439    let window = psx_hw::memory::io::BASE >> 16;
440    assert!(psx_hw::irq::I_STAT >> 16 == window && psx_hw::irq::I_MASK >> 16 == window);
441    assert!(psx_hw::irq::I_STAT & 0xFFFF < 0x8000 && psx_hw::irq::I_MASK & 0xFFFF < 0x8000);
442};
443
444#[cfg(target_arch = "mips")]
445extern "C" {
446    fn psx_cdstream_exception_wrapper();
447}
448
449/// Called by the wrapper, on the private stack, with CPU interrupts off.
450#[no_mangle]
451extern "C" fn psx_cdstream_interrupt() {
452    // SAFETY: exception entry masks CPU interrupts, and every foreground
453    // access closes the CD source before it takes its reference, so this is
454    // the only live reference to the engine.
455    let engine = unsafe { &mut *ENGINE.0.get() };
456    engine.on_interrupt();
457    publish(engine);
458}
459
460// ------------------------------------------------------------------- set-up
461
462/// Take over the CD-ROM controller: put the exception wrapper in front of
463/// psx-rt's handler and start serving requests.
464///
465/// The controller must be idle and nobody else may use it from now on: if the
466/// boot code read with `SectorReader`, call `SectorReader::release` for the
467/// token and pass it here. The token comes back from [`uninstall`] (to read
468/// with the polled reader again) or [`take_audio_lease`] (to play audio).
469///
470/// If psx-rt's handler is not in the vector yet this installs it first
471/// (`install_vblank_counter`); call `install` after any code that installs it
472/// itself, since that call would overwrite the wrapper.
473///
474/// Returns the token if the transport is already installed.
475///
476/// With [`Config::time_handler`] the transport takes Timer 2 for itself.
477pub fn install(cd: Cd, config: Config) -> Result<(), Cd> {
478    if TOKEN.is_held() {
479        return Err(cd);
480    }
481    TOKEN.put(cd);
482    let mask = irq::mask();
483    irq::set_mask(mask & !CD_BIT);
484    if !psx_rt::interrupts::is_stack_safe_handler_installed() {
485        psx_rt::interrupts::install_vblank_counter();
486    }
487    if config.time_handler {
488        // System clock / 8, free running, no interrupt: wraps after about
489        // 15.5 ms.
490        timers::set_mode(Timer::Timer2, psx_hw::timers::mode::clock_source(2));
491    }
492    paint_stack();
493    install_vector();
494    with_engine(|engine| {
495        engine.hw_mut().time_handler = config.time_handler;
496        engine.configure(config);
497        engine.attach();
498    });
499    Ok(())
500}
501
502fn paint_stack() {
503    // SAFETY: the handler cannot run (the source is closed) and nothing else
504    // uses this stack; plain volatile stores over a static this module owns.
505    unsafe {
506        let stack = (&raw mut PSX_CD_IRQ_STACK).cast::<u8>();
507        for i in 0..HANDLER_STACK_BYTES {
508            stack.add(i).write_volatile(STACK_FILL);
509        }
510    }
511}
512
513#[cfg(target_arch = "mips")]
514fn install_vector() {
515    const EXCEPTION_VECTOR: *mut u32 = 0x8000_0080 as *mut u32;
516    const J_OPCODE: u32 = 0x0800_0000;
517    let wrapper = psx_cdstream_exception_wrapper as *const () as usize as u32;
518    // SAFETY: the general exception vector is two words of kernel RAM; the
519    // wrapper switches stacks before it stores anything and hands over to
520    // psx-rt's handler, which is how psx-rt's own installer fills it. The
521    // instruction cache is flushed so the CPU fetches the new words, and the
522    // wrapper is declared stack-safe so scratchpad stacks stay allowed.
523    unsafe {
524        core::ptr::write_volatile(EXCEPTION_VECTOR, J_OPCODE | ((wrapper >> 2) & 0x03ff_ffff));
525        core::ptr::write_volatile(EXCEPTION_VECTOR.add(1), 0);
526        psx_rt::cache::flush_instruction_cache();
527        psx_rt::interrupts::declare_stack_safe_handler(psx_cdstream_exception_wrapper);
528    }
529}
530
531#[cfg(not(target_arch = "mips"))]
532fn install_vector() {}
533
534/// Give the controller back to polled code. Succeeds only when the transport
535/// is idle with nothing queued; call [`cancel_all`] and poll [`is_idle`]
536/// first. The wrapper stays in the exception vector (a few instructions per
537/// exception) but the CD source is closed.
538pub fn uninstall() -> Option<Cd> {
539    let detached = with_engine(Engine::detach);
540    if !detached {
541        return None;
542    }
543    TOKEN.take()
544}
545
546// ------------------------------------------------------------ the foreground
547
548/// Change the configuration; it applies from the next event. See
549/// [`Engine::configure`]. ([`Config::time_handler`] is only read by
550/// [`install`].)
551pub fn configure(config: Config) {
552    with_engine(|engine| engine.configure(config));
553}
554
555/// Start the longest-handler measurement over. See
556/// [`Engine::reset_max_irq_ticks`].
557pub fn reset_max_irq_ticks() {
558    with_engine(Engine::reset_max_irq_ticks);
559}
560
561/// Queue a read. See [`Engine::submit`].
562pub fn submit(request: Request) -> Result<Ticket, SubmitError> {
563    with_engine(|engine| engine.submit(request))
564}
565
566/// Cancel a read. See [`Engine::cancel`].
567pub fn cancel(ticket: Ticket) -> bool {
568    with_engine(|engine| engine.cancel(ticket))
569}
570
571/// Cancel everything. See [`Engine::cancel_all`].
572pub fn cancel_all() {
573    with_engine(Engine::cancel_all);
574}
575
576/// Where a read is. See [`Engine::state`].
577pub fn state(ticket: Ticket) -> RequestState {
578    with_engine(|engine| engine.state(ticket))
579}
580
581/// Check for a stalled transfer. See [`Engine::service`].
582pub fn service() {
583    with_engine(Engine::service);
584}
585
586/// Whether the drive is stopped and no read holds it.
587pub fn is_idle() -> bool {
588    with_engine(|engine| engine.is_idle())
589}
590
591/// Reads waiting behind the active one.
592pub fn queued_count() -> usize {
593    with_engine(|engine| engine.queued_count())
594}
595
596/// Who may program the controller.
597pub fn owner() -> Owner {
598    with_engine(|engine| engine.owner())
599}
600
601/// The controller phase.
602pub fn phase() -> Phase {
603    with_engine(|engine| engine.phase())
604}
605
606/// A copy of the counters.
607pub fn stats() -> StreamStats {
608    with_engine(|engine| engine.stats())
609}
610
611// -------------------------------------------------------------------- audio
612
613/// Ask for the drive on behalf of CD-DA or XA playback. See
614/// [`Engine::request_audio_lease`]; once this reads `Granted`, collect the
615/// controller token with [`take_audio_lease`].
616pub fn request_audio_lease() -> LeaseState {
617    with_engine(|engine| engine.request_audio_lease())
618}
619
620/// Where the lease stands.
621pub fn lease_state() -> LeaseState {
622    with_engine(|engine| engine.lease_state())
623}
624
625/// The controller token, once the lease is granted (and not yet taken). The
626/// audio code programs the controller through it until it hands it back with
627/// [`release_audio_lease`]; the CD interrupt source stays closed meanwhile.
628pub fn take_audio_lease() -> Option<Cd> {
629    with_engine(|engine| {
630        if engine.lease_state() != LeaseState::Granted {
631            return None;
632        }
633        TOKEN.take()
634    })
635}
636
637/// End the lease: hand the controller back after the audio code has ended
638/// playback with Pause (not Stop). Gives the token back if there was no
639/// lease to end.
640pub fn release_audio_lease(cd: Cd) -> Result<(), Cd> {
641    with_engine(|engine| {
642        if engine.owner() != Owner::Audio {
643            return Err(cd);
644        }
645        TOKEN.put(cd);
646        engine.release_audio_lease();
647        Ok(())
648    })
649}
650
651/// Take the drive for audio only if it is free right now, in one step: the
652/// controller token if the lease was granted, `None` if a read is in flight.
653/// Nothing is left pending and the read is not aborted, so a read submitted
654/// afterwards is never parked behind a lease granted later. See
655/// [`Engine::try_audio_lease`]; end the lease with [`release_audio_lease`].
656pub fn try_take_audio_lease() -> Option<Cd> {
657    with_engine(|engine| {
658        if engine.try_audio_lease() {
659            TOKEN.take()
660        } else {
661            None
662        }
663    })
664}
665
666/// Withdraw a lease that was asked for and has not been granted. Returns
667/// whether one was pending. See [`Engine::withdraw_audio_lease`].
668pub fn withdraw_audio_lease() -> bool {
669    with_engine(Engine::withdraw_audio_lease)
670}
671
672// --------------------------------------------------------------- diagnostics
673
674/// Bytes of the handler's private stack never touched since [`install`]
675/// painted it. The handler's own frames plus the saved-context area must fit
676/// in the rest; a value near 0 means it overflowed.
677pub fn handler_stack_unused_bytes() -> usize {
678    let mut unused = 0;
679    // SAFETY: plain volatile reads of a static this module owns.
680    unsafe {
681        let stack = (&raw const PSX_CD_IRQ_STACK).cast::<u8>();
682        while unused < HANDLER_STACK_BYTES && stack.add(unused).read_volatile() == STACK_FILL {
683            unused += 1;
684        }
685    }
686    unused
687}