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

psx_rt/
scratchpad.rs

1//! The CPU scratchpad, and running code with its stack there.
2//!
3//! The R3000A has no data cache. Its 1 KiB scratchpad ([`BASE`]) is
4//! the only data memory that loads from in one cycle; a main-RAM load stalls
5//! for about six. Code whose frames spill a lot (long loops with more live
6//! values than callee-saved registers, explicit work stacks in a frame) pays
7//! that stall on every stack reload. [`ScratchpadStack::run`] moves `$sp`
8//! into a scratchpad region for one call, so the same instructions run with
9//! their frames in fast memory. Nothing about the code changes, only where
10//! its frames live, so results are identical by construction. On Cortex,
11//! running face selection, collision traces and the model face walker this
12//! way cut gameplay work 3.9% in the emulator (2026-09-22). No console has
13//! run it yet.
14//!
15//! # Sharing the scratchpad
16//!
17//! Games and the engine already keep working sets in the scratchpad (clip
18//! planes, batch workspaces, bucket heads), usually in phases that reuse the
19//! same bytes. There is deliberately no allocator. Instead each user names
20//! its bytes as a const [`Region`], and every set of regions that is live at
21//! the same time is checked once at compile time with [`assert_disjoint`]:
22//!
23//! ```
24//! use psx_rt::scratchpad::{assert_disjoint, Region, ScratchpadStack};
25//!
26//! // Persistent across the whole frame.
27//! const BUCKET_HEADS: Region = Region::new(0, 128);
28//! // Face pass only: clip planes, then the batch workspace.
29//! const CLIP_PLANES: Region = Region::new(128, 224);
30//! const BATCH: Region = Region::new(224, 1024);
31//! // Selection runs before the face pass claims its bytes.
32//! type SelectionStack = ScratchpadStack<128, 1024>;
33//!
34//! const _: () = assert_disjoint(&[BUCKET_HEADS, CLIP_PLANES, BATCH]);
35//! const _: () = assert_disjoint(&[BUCKET_HEADS, SelectionStack::REGION]);
36//! ```
37//!
38//! A stack region may overlap a phase's region only when that phase is not
39//! live around the call, and the second `assert_disjoint` is the written
40//! record of which regions are.
41//!
42//! # What makes it safe
43//!
44//! * Interrupts. psx-rt's exception handler (installed by
45//!   `interrupts::install_vblank_counter`) uses only `$k0`/`$k1` and
46//!   never touches `$sp`, so an IRQ taken on a scratchpad stack writes
47//!   nothing there and returns to the same `$sp`. A game that runs with a
48//!   different handler installed must check that it does not push onto the
49//!   interrupted stack, and then says so with
50//!   [`declare_stack_safe_handler`](crate::interrupts::declare_stack_safe_handler)
51//!   (hk-psx's CD wrapper saves to its own context, runs on its own stack
52//!   and chains to psx-rt's handler). The `scratchpad-stack-check` feature
53//!   traps a call made with interrupts enabled and any other handler in
54//!   the vector.
55//! * Stack depth. The whole call tree of `f` must fit in the region minus
56//!   [`STACK_OVERHEAD`] ([`ScratchpadStack::BUDGET`]). `stack-guard`
57//!   proves it from the linked image after every build: it finds each
58//!   monomorphised [`ScratchpadStack`] entry, sums frame sizes down the static
59//!   call graph, and fails on overflow, recursion, calls through a register
60//!   and BIOS calls, none of which it can bound. The `scratchpad-stack-check`
61//!   feature adds a canary word at the bottom of the region, checked after
62//!   every call.
63//! * Panics. SDK guests build with `panic = "abort"`, so nothing unwinds
64//!   through the switch. With `panic_immediate_abort` a panic is a BREAK the
65//!   exception handler halts on. Otherwise psx-rt's panic handler moves `$sp`
66//!   back to the RAM stack before it prints, so reporting never runs on (or
67//!   overflows) the scratchpad.
68//! * Nesting. A call made while already on a scratchpad stack runs `f` in
69//!   place, and the guard counts it as part of the outer call tree.
70
71use core::mem::{ManuallyDrop, MaybeUninit};
72
73/// Address of the first scratchpad byte (KUSEG/KSEG0; there is no KSEG1
74/// alias).
75pub const BASE: usize = psx_hw::memory::scratchpad::BASE as usize;
76
77/// Scratchpad capacity in bytes.
78pub const SIZE: usize = psx_hw::memory::scratchpad::SIZE;
79
80/// Bytes of a stack region a call tree cannot use: the 16-byte o32
81/// argument home area above the first frame, and the canary word at the
82/// bottom of the region (reserved whether or not the check is enabled, so
83/// the budget does not depend on a feature).
84pub const STACK_OVERHEAD: usize = 20;
85
86/// The value the `scratchpad-stack-check` feature keeps in a stack region's
87/// bottom word.
88pub const CANARY: u32 = 0x5C4A_7C4D;
89
90/// A byte range `start..end` of the scratchpad.
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub struct Region {
93    start: usize,
94    end: usize,
95}
96
97impl Region {
98    /// The bytes `start..end`. Fails const evaluation (a compile error in a
99    /// `const`) when the range is empty or runs past [`SIZE`].
100    pub const fn new(start: usize, end: usize) -> Self {
101        assert!(start < end, "scratchpad region is empty");
102        assert!(
103            end <= SIZE,
104            "scratchpad region runs past the 1 KiB scratchpad"
105        );
106        Self { start, end }
107    }
108
109    /// First byte offset.
110    pub const fn start(self) -> usize {
111        self.start
112    }
113
114    /// One past the last byte offset.
115    pub const fn end(self) -> usize {
116        self.end
117    }
118
119    /// Length in bytes.
120    pub const fn len(self) -> usize {
121        self.end - self.start
122    }
123
124    /// Always false: a region cannot be empty.
125    pub const fn is_empty(self) -> bool {
126        false
127    }
128
129    /// Absolute address of the first byte.
130    pub const fn addr(self) -> usize {
131        BASE + self.start
132    }
133
134    /// True when the two ranges share a byte.
135    pub const fn overlaps(self, other: Region) -> bool {
136        self.start < other.end && other.start < self.end
137    }
138}
139
140/// Fail const evaluation when any two of `regions` overlap.
141///
142/// Pass every region that is live at one time, and call it in a `const _`
143/// item so an overlap is a compile error rather than a corrupted frame.
144///
145/// ```compile_fail
146/// use psx_rt::scratchpad::{assert_disjoint, Region};
147/// const _: () = assert_disjoint(&[Region::new(0, 64), Region::new(32, 96)]);
148/// ```
149pub const fn assert_disjoint(regions: &[Region]) {
150    let mut i = 0;
151    while i < regions.len() {
152        let mut j = i + 1;
153        while j < regions.len() {
154            if regions[i].overlaps(regions[j]) {
155                panic!("scratchpad regions that are live together overlap");
156            }
157            j += 1;
158        }
159        i += 1;
160    }
161}
162
163/// A call stack in scratchpad bytes `START..END`.
164///
165/// The stack grows down from `END - 16`; the bottom word at `START` is the
166/// canary. See the [module docs](self) for the rules.
167///
168/// A layout the stack cannot use is a compile error where it is run:
169///
170/// ```compile_fail
171/// use psx_rt::scratchpad::ScratchpadStack;
172/// // END is not 8-byte aligned.
173/// const _: usize = ScratchpadStack::<0, 1020>::BUDGET;
174/// ```
175pub struct ScratchpadStack<const START: usize, const END: usize>;
176
177impl<const START: usize, const END: usize> ScratchpadStack<START, END> {
178    /// The bytes this stack owns while a call runs on it. Evaluating it
179    /// checks the layout: in bounds, `START` word aligned, `END` 8-byte
180    /// aligned (the o32 stack alignment) and room for more than the
181    /// overhead.
182    pub const REGION: Region = {
183        let region = Region::new(START, END);
184        assert!(
185            START.is_multiple_of(4),
186            "scratchpad stack START must be word aligned"
187        );
188        assert!(
189            END.is_multiple_of(8),
190            "scratchpad stack END must be 8-byte aligned"
191        );
192        assert!(
193            region.len() > STACK_OVERHEAD,
194            "scratchpad stack region is smaller than its overhead"
195        );
196        region
197    };
198
199    /// Bytes of frames the call tree may use; `stack-guard` fails
200    /// the build when the linked tree needs more.
201    pub const BUDGET: usize = Self::REGION.len() - STACK_OVERHEAD;
202
203    /// Run `f` with `$sp` in this region and return its result.
204    ///
205    /// The switch is a 16-instruction trampoline after a 4-instruction `$sp`
206    /// test. Already on a scratchpad stack, it calls `f` in place. On the host there is no scratchpad and
207    /// it only calls `f`.
208    ///
209    /// # Safety
210    ///
211    /// * No scratchpad data another piece of code relies on may live in
212    ///   [`Self::REGION`] while `f` runs, and `f` must not put any there.
213    ///   Record the regions that are live around the call with
214    ///   [`assert_disjoint`].
215    /// * The linked call tree of `f` must fit in [`Self::BUDGET`]. Run
216    ///   `stack-guard` on every linked image that calls this.
217    /// * Any exception handler installed while interrupts are enabled must
218    ///   leave `$sp` and the memory below it alone (psx-rt's does; declare
219    ///   another with
220    ///   [`declare_stack_safe_handler`](crate::interrupts::declare_stack_safe_handler)).
221    #[inline(always)]
222    pub unsafe fn run<R, F: FnOnce() -> R>(f: F) -> R {
223        // Force the layout checks for every instantiation.
224        let region = Self::REGION;
225        if is_on_scratchpad_stack() {
226            return f();
227        }
228        let mut frame = Frame::<F, R> {
229            f: ManuallyDrop::new(f),
230            result: MaybeUninit::uninit(),
231        };
232        #[cfg(feature = "scratchpad-stack-check")]
233        check::before(region);
234        // SAFETY: the stack top is 8-byte aligned and inside the scratchpad
235        // (REGION's checks); the entry reads `frame` exactly once and writes
236        // the result before returning. The caller upholds the rest.
237        unsafe {
238            call_on_stack(
239                (&raw mut frame).cast::<u8>(),
240                Self::stack_entry::<R, F>,
241                BASE + region.end() - 16,
242            );
243        }
244        #[cfg(feature = "scratchpad-stack-check")]
245        check::after(region);
246        // SAFETY: stack_entry initialised it, and it cannot return early:
247        // a panic aborts.
248        unsafe { frame.result.assume_init() }
249    }
250
251    /// First function on the scratchpad stack. `stack-guard` finds
252    /// every monomorphised copy by this name and reads `START`/`END` from
253    /// the symbol, so do not rename it without updating the tool.
254    unsafe extern "C" fn stack_entry<R, F: FnOnce() -> R>(frame: *mut u8) {
255        // SAFETY: `run` passes its own live `Frame<F, R>`.
256        let frame = unsafe { &mut *frame.cast::<Frame<F, R>>() };
257        // SAFETY: taken exactly once; `run` never drops `frame.f` itself.
258        let f = unsafe { ManuallyDrop::take(&mut frame.f) };
259        frame.result.write(f());
260    }
261}
262
263/// The closure and its result, kept in the caller's RAM frame.
264struct Frame<F, R> {
265    f: ManuallyDrop<F>,
266    result: MaybeUninit<R>,
267}
268
269/// True while `$sp` points into the scratchpad. Always false on the host.
270#[inline(always)]
271pub fn is_on_scratchpad_stack() -> bool {
272    #[cfg(target_arch = "mips")]
273    {
274        crate::stack_pointer().wrapping_sub(BASE) <= SIZE
275    }
276    #[cfg(not(target_arch = "mips"))]
277    {
278        false
279    }
280}
281
282// void __psx_rt_call_on_stack(void *frame, void (*entry)(void *), void *sp)
283//
284// Saves the caller's $sp in $s0 (callee-saved, so the entry preserves it)
285// and in __psx_rt_scratchpad_return_sp (for the panic handler), sets $sp in
286// the call's delay slot, calls entry(frame), then restores. Its own 24-byte
287// frame stays on the caller's stack. An IRQ in the delay slot re-executes
288// the jalr and the move, which are idempotent.
289#[cfg(target_arch = "mips")]
290core::arch::global_asm!(
291    r#"
292    .set noreorder
293    .set nomacro
294    .section .text.psx_rt_scratchpad,"ax",@progbits
295    .globl __psx_rt_call_on_stack
296    .type __psx_rt_call_on_stack,@function
297__psx_rt_call_on_stack:
298    addiu $sp, $sp, -24
299    sw    $ra, 20($sp)
300    sw    $s0, 16($sp)
301    move  $s0, $sp
302    lui   $t0, %hi(__psx_rt_scratchpad_return_sp)
303    sw    $sp, %lo(__psx_rt_scratchpad_return_sp)($t0)
304    move  $t9, $a1
305    jalr  $t9
306    move  $sp, $a2
307    lui   $t0, %hi(__psx_rt_scratchpad_return_sp)
308    sw    $zero, %lo(__psx_rt_scratchpad_return_sp)($t0)
309    move  $sp, $s0
310    lw    $ra, 20($sp)
311    lw    $s0, 16($sp)
312    jr    $ra
313    addiu $sp, $sp, 24
314    .size __psx_rt_call_on_stack, .-__psx_rt_call_on_stack
315
316    # Its own section: the panic handler keeps this one in every guest, and
317    # only guests that use a ScratchpadStack should carry the switch above
318    # (stack-guard asks for a link map when it finds it).
319    .section .text.psx_rt_scratchpad_panic,"ax",@progbits
320    .globl __psx_rt_jump_on_stack
321    .type __psx_rt_jump_on_stack,@function
322__psx_rt_jump_on_stack:
323    jr    $a2
324    move  $sp, $a1
325    .size __psx_rt_jump_on_stack, .-__psx_rt_jump_on_stack
326    .set macro
327    .set reorder
328    "#
329);
330
331/// The RAM `$sp` below the frame of the outermost [`ScratchpadStack::run`]
332/// in progress; zero when none is.
333#[cfg(target_arch = "mips")]
334#[no_mangle]
335static mut __psx_rt_scratchpad_return_sp: usize = 0;
336
337#[cfg(target_arch = "mips")]
338extern "C" {
339    fn __psx_rt_call_on_stack(frame: *mut u8, entry: unsafe extern "C" fn(*mut u8), sp: usize);
340    fn __psx_rt_jump_on_stack(arg: *const u8, sp: usize, entry: extern "C" fn(*const u8) -> !)
341        -> !;
342}
343
344#[cfg(target_arch = "mips")]
345#[inline(always)]
346unsafe fn call_on_stack(frame: *mut u8, entry: unsafe extern "C" fn(*mut u8), sp: usize) {
347    // SAFETY: the caller (`ScratchpadStack::run`) passes a live frame, the entry that matches it,
348    // and an 8-byte-aligned stack top inside the scratchpad. The trampoline saves $ra and $s0 on
349    // the RAM stack, switches $sp, calls `entry(frame)`, and restores $sp, so the O32 contract
350    // holds across it.
351    unsafe { __psx_rt_call_on_stack(frame, entry, sp) }
352}
353
354#[cfg(not(target_arch = "mips"))]
355#[inline(always)]
356unsafe fn call_on_stack(frame: *mut u8, entry: unsafe extern "C" fn(*mut u8), _sp: usize) {
357    // SAFETY: the caller passes a live frame and the entry that matches it, as on the target; the
358    // host has no scratchpad, so `entry` simply runs on the current stack.
359    unsafe { entry(frame) }
360}
361
362/// If a panic starts on a scratchpad stack, continue `report(arg)` on the
363/// RAM stack the outermost [`ScratchpadStack::run`] left, and never
364/// return. Returns when the panic is already on the RAM stack.
365#[cfg(target_arch = "mips")]
366#[inline(always)]
367pub(crate) fn leave_for_panic(arg: *const u8, report: extern "C" fn(*const u8) -> !) {
368    if !is_on_scratchpad_stack() {
369        return;
370    }
371    // SAFETY: a plain read; the value is either zero or a RAM stack
372    // pointer below every live RAM frame.
373    let sp = unsafe { core::ptr::read_volatile(&raw const __psx_rt_scratchpad_return_sp) };
374    if sp != 0 {
375        // SAFETY: nothing on the scratchpad stack is used again.
376        unsafe { __psx_rt_jump_on_stack(arg, sp, report) }
377    }
378}
379
380#[cfg(feature = "scratchpad-stack-check")]
381mod check {
382    use super::Region;
383
384    #[cfg(target_arch = "mips")]
385    fn canary(region: Region) -> *mut u32 {
386        (super::BASE + region.start()) as *mut u32
387    }
388
389    #[cfg(target_arch = "mips")]
390    pub(super) fn before(region: Region) {
391        if crate::interrupts::are_interrupts_enabled()
392            && !crate::interrupts::is_stack_safe_handler_installed()
393        {
394            panic!(
395                "scratchpad stack: interrupts are on and the exception vector is neither psx-rt's \
396                 handler nor one declared with interrupts::declare_stack_safe_handler"
397            );
398        }
399        // SAFETY: the word is inside the scratchpad and owned by the call.
400        unsafe { canary(region).write_volatile(super::CANARY) }
401    }
402
403    #[cfg(target_arch = "mips")]
404    pub(super) fn after(region: Region) {
405        // SAFETY: as in `before`.
406        if unsafe { canary(region).read_volatile() } != super::CANARY {
407            panic!("scratchpad stack overran its region");
408        }
409    }
410
411    #[cfg(not(target_arch = "mips"))]
412    pub(super) fn before(_region: Region) {}
413
414    #[cfg(not(target_arch = "mips"))]
415    pub(super) fn after(_region: Region) {}
416}
417
418#[cfg(test)]
419mod tests {
420    extern crate std;
421    use super::*;
422
423    const HEADS: Region = Region::new(0, 128);
424    const PLANES: Region = Region::new(128, 224);
425    const BATCH: Region = Region::new(224, 1024);
426    type Stack = ScratchpadStack<128, 1024>;
427    const _: () = assert_disjoint(&[HEADS, PLANES, BATCH]);
428    const _: () = assert_disjoint(&[HEADS, Stack::REGION]);
429
430    #[test]
431    fn regions_report_their_bytes() {
432        assert_eq!(BATCH.len(), 800);
433        assert_eq!(BATCH.addr(), BASE + 0xE0);
434        assert!(PLANES.overlaps(Stack::REGION));
435        assert!(!HEADS.overlaps(PLANES));
436        assert!(Region::new(0, 8).overlaps(Region::new(4, 12)));
437        assert!(!Region::new(0, 8).overlaps(Region::new(8, 12)));
438    }
439
440    #[test]
441    fn budget_leaves_the_home_area_and_canary() {
442        assert_eq!(Stack::BUDGET, 896 - STACK_OVERHEAD);
443        assert_eq!(ScratchpadStack::<0, 1024>::BUDGET, 1004);
444    }
445
446    #[test]
447    #[should_panic(expected = "overlap")]
448    fn overlapping_live_regions_are_rejected() {
449        assert_disjoint(core::hint::black_box(&[HEADS, BATCH, Region::new(96, 160)]));
450    }
451
452    #[test]
453    #[should_panic(expected = "past")]
454    fn regions_stay_inside_the_scratchpad() {
455        Region::new(core::hint::black_box(512), 1028);
456    }
457
458    #[test]
459    #[should_panic(expected = "empty")]
460    fn empty_regions_are_rejected() {
461        Region::new(core::hint::black_box(64), 64);
462    }
463
464    #[test]
465    fn run_returns_the_closure_result_and_moves_captures() {
466        let mut touched = 0u32;
467        let text = std::string::String::from("moved");
468        // SAFETY: on the host `run` only calls the closure, so none of its scratchpad, stack-budget
469        // or exception-handler obligations apply.
470        let (len, words) = unsafe {
471            Stack::run(|| {
472                touched += 1;
473                (text.len(), [7u32; 9])
474            })
475        };
476        assert_eq!((touched, len, words), (1, 5, [7; 9]));
477        assert!(!is_on_scratchpad_stack());
478    }
479}