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}