Skip to main content

rustyn64_rsp/
lib.rs

1//! `rustyn64-rsp` — RSP (Reality Signal Processor), the RCP's vector coprocessor.
2//!
3//! The RSP is a MIPS-derived scalar unit (SU) plus a 32-lane × 8 × 16-bit SIMD
4//! vector unit (VU), running game-supplied microcode out of its 4 KiB IMEM with
5//! 4 KiB of DMEM scratch. It drives geometry transform (display lists → RDP
6//! commands) and audio mixing. The accuracy bar is **LLE** (low-level emulation
7//! — interpret the microcode instruction-by-instruction, the cen64 / ares model)
8//! rather than HLE microcode recognition.
9//!
10//! Both the **scalar unit** ([`su`]) and the full **8-lane vector unit** ([`vu`])
11//! run: the 48-bit accumulator, the `VRCP`/`VRSQ` reciprocal ROM tables, the
12//! clamping rules, and the whole vector load/store family are implemented, and
13//! the SP interface registers are modeled ([`sp`]). The RSP category of
14//! n64-systemtest passes `Failed: 0` (Phase 2).
15//!
16//! The RSP never borrows the rest of the machine. [`Rsp::tick`] *returns* what
17//! it wants done — a DMA to perform, an interrupt to raise — and
18//! `rustyn64-core::Bus` carries it out, because the RSP owns neither RDRAM nor
19//! the MI. That keeps this crate independent of the other chip crates and lets
20//! the RSP be stepped in isolation.
21//!
22//! Part of the one-directional chip-crate graph (see `docs/architecture.md`):
23//! this crate does NOT depend on any other chip crate. `#![no_std]` + `alloc`;
24//! only the frontend carries `std` + `unsafe`.
25
26#![no_std]
27#![forbid(unsafe_code)]
28#![warn(missing_docs)]
29#![allow(clippy::cast_possible_truncation, clippy::cast_lossless)]
30// Several step methods take `&mut self` and are not all `const`; accept the
31// pedantic suggestions at module level rather than annotate each one.
32#![allow(
33    clippy::missing_const_for_fn,
34    clippy::unused_self,
35    clippy::needless_pass_by_ref_mut
36)]
37
38extern crate alloc;
39
40use serde::{Deserialize, Serialize};
41
42pub mod sp;
43pub mod su;
44pub mod vu;
45
46/// Size of RSP DMEM / IMEM (each 4 KiB).
47pub const SP_MEM_SIZE: usize = 4 * 1024;
48
49/// A zeroed COP2 `funct` histogram, for `#[serde(skip)]`.
50///
51/// **`default` is not optional here.** On this toolchain `[u64; 64]` does not
52/// implement `Default`, so `#[serde(skip)]` alone does not compile:
53///
54/// ```text
55/// error[E0277]: the trait bound `[u64; 64]: Default` is not satisfied
56/// ```
57///
58/// Stated as the observed compiler behavior rather than as a rule about where
59/// the array impls stop, because a reviewer read it as the latter and disputed
60/// it. The reproduction is one line: `let _: [u64; 64] = Default::default();`.
61///
62/// That is the compiler catching, at the type level, the same class of mistake
63/// #245 shipped at runtime: a skipped field that deserializes into something
64/// unusable.
65#[cfg(feature = "work-counters")]
66fn zeroed_funct_histogram() -> alloc::boxed::Box<[u64; 64]> {
67    alloc::boxed::Box::new([0; 64])
68}
69
70/// RSP architectural state.
71///
72/// Holds the SU register file, the VU vector register file + accumulator, the
73/// program counter into IMEM, the halted flag, and the DMEM/IMEM scratch. The
74/// execution engine (SU + VU) runs the microcode instruction stream.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct Rsp {
77    /// Scalar unit: 32 × 32-bit general registers.
78    pub su_regs: [u32; 32],
79    /// Vector unit: 32 registers × 8 lanes of 16-bit.
80    pub vu_regs: [[u16; 8]; 32],
81    /// 48-bit-per-lane VU accumulator (modeled as `[u64; 8]`, low 48 used).
82    pub vu_acc: [u64; 8],
83    /// **Vestigial.** The authoritative PC lives in [`Self::sp`]
84    /// (`SpRegs::pc()`), which is what `su_step` fetches from; this
85    /// field is never written. Kept only so the save-state layout is unchanged —
86    /// removing it is a format break (ADR 0005), which module 70 reserves for an
87    /// announced major release — and **deprecated** so any read is a loud warning
88    /// (the workspace treats warnings as errors). Use [`Rsp::pc`] instead.
89    ///
90    /// This is not hypothetical tidiness: while it was `pub` it was sampled twice
91    /// in one debugging session and produced two confident, wrong conclusions —
92    /// "the RSP never starts" and "its PC never advances" — when in fact retail
93    /// microcode was executing hundreds of distinct instructions. That is the
94    /// inert-API hazard in `docs/engineering-lessons.md` §3.2 exactly.
95    #[deprecated(
96        since = "0.8.0",
97        note = "never written; read `Rsp::pc()` instead, which returns SP_STATUS's PC"
98    )]
99    pub pc: u16,
100    /// **Vestigial** — see [`Self::pc`]. The authoritative halt state is
101    /// `sp.halted()` (`SP_STATUS.halt`), which is what gates execution. Use
102    /// [`Rsp::halted`] instead.
103    #[deprecated(
104        since = "0.8.0",
105        note = "never written; read `Rsp::halted()` instead, which returns SP_STATUS.halt"
106    )]
107    pub halted: bool,
108    /// 4 KiB data memory.
109    #[serde(with = "rustyn64_snapshot::boxed_bytes")]
110    pub dmem: alloc::boxed::Box<[u8; SP_MEM_SIZE]>,
111    /// 4 KiB instruction memory.
112    #[serde(with = "rustyn64_snapshot::boxed_bytes")]
113    pub imem: alloc::boxed::Box<[u8; SP_MEM_SIZE]>,
114    /// The SP interface registers, shared by the CPU's memory-mapped window and
115    /// the RSP's own COP0 -- one set of physical registers, so one field.
116    pub sp: sp::SpRegs,
117    /// Shadow of the eight DP command registers (COP0 `c8`–`c15` =
118    /// `DP_START`/`END`/`CURRENT`/`STATUS`/…), so an `MFC0` reads back what a
119    /// prior `MTC0` in the same run wrote. The authoritative copy lives in
120    /// `rustyn64-rdp`; the Bus forwards each write there (`StepResult::dp_write`)
121    /// and this shadow is the RSP-local view — which is why reads of DP state the
122    /// RDP mutated on its own are not yet reflected here (Phase 3).
123    pub dp: [u32; 8],
124    /// The VU's three control registers (`VCO`, `VCC`, `VCE`).
125    pub vu_ctrl: vu::Control,
126    /// The reciprocal unit's staging latches (`DIVIN`/`DIVOUT`/`DIVDP`).
127    pub div: vu::Divide,
128    /// The destination lane a single-lane VU instruction computed, applied
129    /// after the accumulator write so the two cannot alias.
130    pending_vd_lane: Option<(usize, u16)>,
131    /// A branch target latched by the previous instruction, taken after the
132    /// delay slot retires. `None` means the next PC is sequential.
133    branch: Option<u32>,
134    /// Instructions this RSP has **executed** (`work-counters`).
135    ///
136    /// Not a cycle counter and nothing schedules against it — the project rule
137    /// is that `master_ticks` is the only counter that is ever incremented, and
138    /// its stated exception is exactly this: a retired-work tally. Reading it
139    /// cannot affect timing, and nothing in the emulator does read it.
140    ///
141    /// It counts **executed**, not stepped, and the increment sits *after*
142    /// `su_step`'s halt check for that reason. A halted RSP is stepped every RCP
143    /// cycle and does nothing; counting those would make the tally track the
144    /// scheduler instead of the microcode — which is the opposite of what it is
145    /// for, and would look perfectly plausible. `a_halted_rsp_retires_nothing`
146    /// pins it.
147    ///
148    /// `#[serde(skip)]`, so ADR 0005's save-state layout is untouched.
149    #[cfg(feature = "work-counters")]
150    #[serde(skip)]
151    retired: u64,
152    /// How many times each COP2 computational `funct` has executed
153    /// (`work-counters`).
154    ///
155    /// `vu.rs` is 143 functions and ~8.5% of a frame, so it matters enormously
156    /// WHICH operations a real workload actually runs — the alternative to
157    /// counting is 143 hand-optimized functions to recover the cost of the five
158    /// that matter.
159    ///
160    /// **This crate is `#![forbid(unsafe_code)]`.** ADR 0016 authorizes a narrow
161    /// exception — `core::arch` intrinsics, in `vu.rs` only — behind four gates,
162    /// the first of which is a scalar/vector **equivalence** test over the
163    /// operand space, because conformance to the ROM suite is explicitly not
164    /// sufficient. Read it before reaching for an intrinsic; it records the
165    /// trade as marginal rather than recommended.
166    ///
167    /// 64 slots because `funct` is six bits. `#[serde(skip)]`, and a
168    /// retired-work tally like [`Self::retired`].
169    #[cfg(feature = "work-counters")]
170    #[serde(skip, default = "zeroed_funct_histogram")]
171    vu_funct: alloc::boxed::Box<[u64; 64]>,
172    // TODO(T-RSP-01): VCO/VCC/VCE flag registers, the divide-in/out latches for
173    // VRCP/VRSQ, the DMA length/skip latches — see `docs/rsp.md`.
174}
175
176impl Default for Rsp {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182impl Rsp {
183    /// Construct at power-on (halted, zeroed scratch).
184    #[must_use]
185    #[expect(
186        deprecated,
187        reason = "the vestigial `pc`/`halted` fields must still be initialized so                   the save-state layout is unchanged; they are never read"
188    )]
189    pub fn new() -> Self {
190        Self {
191            su_regs: [0; 32],
192            vu_regs: [[0; 8]; 32],
193            vu_acc: [0; 8],
194            pc: 0,
195            halted: true,
196            dmem: alloc::boxed::Box::new([0; SP_MEM_SIZE]),
197            imem: alloc::boxed::Box::new([0; SP_MEM_SIZE]),
198            sp: sp::SpRegs::new(),
199            dp: [0; 8],
200            vu_ctrl: vu::Control {
201                vco: 0,
202                vcc: 0,
203                vce: 0,
204            },
205            div: vu::Divide {
206                input: 0,
207                output: 0,
208                pending: false,
209            },
210            pending_vd_lane: None,
211            branch: None,
212            #[cfg(feature = "work-counters")]
213            retired: 0,
214            #[cfg(feature = "work-counters")]
215            vu_funct: alloc::boxed::Box::new([0; 64]),
216        }
217    }
218
219    /// Byte offset within the CPU-visible SP memory window, folded into the one
220    /// 8 KiB image that DMEM and IMEM form.
221    ///
222    /// The window at `0x0400_0000` is 8 KiB of real storage repeated all the way
223    /// to `0x0404_0000` — n64-systemtest writes `0x3E000` and reads the result
224    /// back at offset 0 (`sp_memory::SW (out of bounds)`), which is the same
225    /// 8 KiB seen for the 31st time. Masking is therefore the behavior, not a
226    /// bounds-check standing in for one: there is no out-of-range access to
227    /// reject inside the window. Provenance is recorded in accuracy ledger
228    /// **C-30** — the wiki documents only the first 8 KiB, so the mirroring
229    /// rests on the oracle.
230    const fn fold(off: u32) -> usize {
231        (off & 0x1FFF) as usize
232    }
233
234    /// The RSP's program counter into IMEM — the value execution actually uses.
235    ///
236    /// Delegates to `SP_STATUS`'s register file rather than the struct field of
237    /// the same name, which is vestigial.
238    ///
239    /// Returns `u32`, not the `u16` the vestigial field used, because that is
240    /// `SpRegs::pc()`'s type and widening here would be a lossless re-narrowing
241    /// for every caller. The value is a 12-bit IMEM offset either way — `su_step`
242    /// masks it with `0xFFC` — so the wider type costs nothing and avoids a cast
243    /// at every call site.
244    #[must_use]
245    pub const fn pc(&self) -> u32 {
246        self.sp.pc()
247    }
248
249    /// Is the RSP halted? This is `SP_STATUS.halt`, the flag that actually gates
250    /// [`Rsp::tick`] — not the vestigial `halted` field.
251    #[must_use]
252    pub const fn halted(&self) -> bool {
253        self.sp.halted()
254    }
255
256    /// Read a byte of DMEM/IMEM as the CPU sees it.
257    ///
258    /// Bit 12 of the folded offset selects IMEM over DMEM, and each bank wraps
259    /// within its own 4 KiB — a transfer or access never spills from one into
260    /// the other (N64brew *RSP Interface* §DMEM and IMEM).
261    #[must_use]
262    pub fn mem_read(&self, off: u32) -> u8 {
263        let off = Self::fold(off);
264        let bank = if off & 0x1000 == 0 {
265            &self.dmem
266        } else {
267            &self.imem
268        };
269        bank[off & 0xFFF]
270    }
271
272    /// Write a byte of DMEM/IMEM as the CPU sees it.
273    pub const fn mem_write(&mut self, off: u32, val: u8) {
274        let off = Self::fold(off);
275        let bank = if off & 0x1000 == 0 {
276            &mut self.dmem
277        } else {
278            &mut self.imem
279        };
280        bank[off & 0xFFF] = val;
281    }
282
283    /// Instructions this RSP has executed since power-on (`work-counters`).
284    ///
285    /// See the field for why this is a legitimate counter under the
286    /// derive-don't-increment rule.
287    #[cfg(feature = "work-counters")]
288    #[must_use]
289    pub const fn retired(&self) -> u64 {
290        self.retired
291    }
292
293    /// Per-`funct` COP2 computational execution counts (`work-counters`).
294    #[cfg(feature = "work-counters")]
295    #[must_use]
296    pub const fn vu_funct_histogram(&self) -> &[u64; 64] {
297        &self.vu_funct
298    }
299
300    /// Count one COP2 computational dispatch of `funct`.
301    #[cfg_attr(
302        not(feature = "work-counters"),
303        allow(
304            clippy::unused_self,
305            reason = "the body is empty without the feature; the call site stays uniform"
306        )
307    )]
308    #[inline(always)]
309    #[allow(
310        clippy::inline_always,
311        reason = "called once per VU instruction; a call would cost more than the count"
312    )]
313    pub(crate) fn count_vu_funct(&mut self, funct: u32) {
314        #[cfg(feature = "work-counters")]
315        {
316            let slot = (funct & 0x3F) as usize;
317            self.vu_funct[slot] = self.vu_funct[slot].wrapping_add(1);
318        }
319        #[cfg(not(feature = "work-counters"))]
320        {
321            let _ = funct;
322        }
323    }
324
325    /// Count one **executed** instruction.
326    ///
327    /// A method rather than a `cfg` block at the call site so that site stays
328    /// one line: `su_step` was already within two lines of the `too_many_lines`
329    /// gate, and an inline block tripped it. Compiles to nothing without the
330    /// feature.
331    #[cfg_attr(
332        not(feature = "work-counters"),
333        allow(
334            clippy::unused_self,
335            clippy::missing_const_for_fn,
336            reason = "the body is empty without the feature; the call site stays uniform"
337        )
338    )]
339    #[inline(always)]
340    #[allow(
341        clippy::inline_always,
342        reason = "called once per executed RSP instruction; a call would cost more than the count"
343    )]
344    pub(crate) const fn count_retired(&mut self) {
345        #[cfg(feature = "work-counters")]
346        {
347            self.retired = self.retired.wrapping_add(1);
348        }
349    }
350
351    /// Advance the RSP by one instruction when running.
352    ///
353    /// Returns what the step asked of the rest of the machine — see
354    /// [`su::StepResult`]. It **reports** rather than acting because the RSP
355    /// owns neither RDRAM nor the MI, and a chip reaching back into its owner is
356    /// the dependency cycle `docs/architecture.md` exists to prevent.
357    ///
358    /// This is also why it no longer borrows a bus: the caller needed to move
359    /// the whole chip out of the Bus to satisfy the borrow checker, and moving
360    /// it out meant `Default`-constructing a replacement — **two 4 KiB
361    /// allocations on every RCP step**, behind a comment that claimed there were
362    /// none.
363    pub fn tick(&mut self) -> su::StepResult {
364        // `su_step` fetches and decodes one instruction and dispatches it — the
365        // scalar ops directly, and the COP2 / vector load-store family through to
366        // the VU (`cop2`, `vector_memory`). So a single `tick` runs the full
367        // scalar+vector engine, which is why the RSP category is `Failed: 0`.
368        self.su_step()
369    }
370}
371
372/// Returns the crate version string.
373#[must_use]
374pub const fn version() -> &'static str {
375    env!("CARGO_PKG_VERSION")
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    /// **The RSP powers up halted** — asserted through [`Rsp::halted`], the
383    /// accessor that reads `SP_STATUS`.
384    ///
385    /// It previously asserted on the `halted` *field*, which is never written and
386    /// is therefore unconditionally `true`. The test passed for a reason that had
387    /// nothing to do with the RSP's actual state, and would have kept passing if
388    /// `SP_STATUS` powered up running.
389    /// A halted RSP is stepped every RCP cycle and must retire nothing.
390    ///
391    /// This is the whole reason the increment sits *after* the halt check.
392    /// Counting ticks instead of executed instructions would make the tally
393    /// track the scheduler rather than the microcode — and it would look
394    /// perfectly plausible, because a halted RSP is stepped constantly.
395    #[cfg(feature = "work-counters")]
396    #[test]
397    fn a_halted_rsp_retires_nothing() {
398        let mut rsp = Rsp::new();
399        assert!(rsp.halted(), "the premise: it powers up halted");
400        for _ in 0..64 {
401            rsp.tick();
402        }
403        assert_eq!(
404            rsp.retired(),
405            0,
406            "a halted RSP retired instructions; the counter is counting ticks"
407        );
408    }
409
410    /// A running RSP retires exactly one instruction per tick.
411    ///
412    /// The non-vacuous half of the pair above: without it, a counter that never
413    /// incremented at all would satisfy `a_halted_rsp_retires_nothing`
414    /// perfectly.
415    #[cfg(feature = "work-counters")]
416    #[test]
417    fn a_running_rsp_retires_one_per_tick() {
418        let mut rsp = Rsp::new();
419        // NOPs, so nothing branches, halts or faults — the count is then
420        // unambiguously one per tick rather than one per something else.
421        for i in 0..16u32 {
422            let a = (i * 4) as usize;
423            rsp.imem[a..a + 4].copy_from_slice(&0u32.to_be_bytes());
424        }
425        rsp.sp.set_halted(false);
426        assert!(!rsp.halted(), "the premise: it is running");
427
428        for n in 1..=8u64 {
429            rsp.tick();
430            assert_eq!(
431                rsp.retired(),
432                n,
433                "after {n} ticks of a running RSP the tally should be {n}"
434            );
435        }
436    }
437
438    /// `retired` is `#[serde(skip)]`, so a restored RSP starts at zero and keeps
439    /// counting — the mirror of `rustyn64-core`'s
440    /// `a_deserialized_bus_starts_at_zero_and_still_counts`.
441    ///
442    /// This PR added two counters and originally tested the save-state
443    /// semantics of only one. An asymmetry like that is how the untested half
444    /// later turns out to behave differently: #245 shipped a `#[serde(skip)]`
445    /// field that deserialized into something unusable and panicked on the next
446    /// write, and the test that should have caught it passed vacuously.
447    #[cfg(feature = "work-counters")]
448    #[test]
449    fn a_deserialized_rsp_starts_at_zero_and_still_counts() {
450        let mut rsp = Rsp::new();
451        rsp.imem[0..4].copy_from_slice(&0u32.to_be_bytes());
452        rsp.sp.set_halted(false);
453        rsp.tick();
454        assert!(rsp.retired() > 0, "the premise: it counted something");
455
456        let bytes = bincode::serialize(&rsp).expect("serialize");
457        let mut restored: Rsp = bincode::deserialize(&bytes).expect("deserialize");
458        assert_eq!(
459            restored.retired(),
460            0,
461            "the tally survived a save-state; it is a measurement, not machine state"
462        );
463        // And it is still live afterwards, which zero alone cannot show.
464        restored.sp.set_halted(false);
465        restored.tick();
466        assert_eq!(restored.retired(), 1, "a restored RSP stopped counting");
467    }
468
469    /// A COP2 computational instruction increments its own bucket, and the
470    /// histogram survives no save-state.
471    ///
472    /// Two properties in one test because each alone is satisfiable by a broken
473    /// counter: an all-zero histogram passes a reset check, and a counter that
474    /// increments the wrong bucket passes a total-count check.
475    ///
476    /// `VMUDN` (`funct 0x06`) chosen because it is the sixth-hottest op in the
477    /// census and unambiguously computational.
478    #[cfg(feature = "work-counters")]
479    #[test]
480    fn a_cop2_instruction_increments_its_own_bucket() {
481        /// `VMUDN`'s `funct`, and its own bucket in the histogram.
482        const VMUDN_FUNCT: usize = 0x06;
483
484        let mut rsp = Rsp::new();
485        // COP2 computational: opcode 0x12, with `rs` bit 4 set to select the
486        // computational group rather than the moves.
487        let word = (0x12u32 << 26) | (0x10u32 << 21) | VMUDN_FUNCT as u32;
488        rsp.imem[0..4].copy_from_slice(&word.to_be_bytes());
489        rsp.sp.set_halted(false);
490        rsp.tick();
491
492        assert_eq!(
493            rsp.vu_funct_histogram()[VMUDN_FUNCT],
494            1,
495            "the executed funct's own bucket did not increment"
496        );
497        assert_eq!(
498            rsp.vu_funct_histogram().iter().sum::<u64>(),
499            1,
500            "exactly one bucket should have moved"
501        );
502
503        // And it is a measurement, not machine state.
504        let bytes = bincode::serialize(&rsp).expect("serialize");
505        let mut restored: Rsp = bincode::deserialize(&bytes).expect("deserialize");
506        assert_eq!(
507            restored.vu_funct_histogram().iter().sum::<u64>(),
508            0,
509            "the histogram survived a save-state"
510        );
511        // Still LIVE, shown by EXECUTING rather than by inspecting the
512        // container. Asserting the histogram's `.len()` would prove nothing:
513        // the field is `Box<[u64; 64]>`, a fixed-size array, so its length is a
514        // compile-time constant. The empty-container failure mode that check
515        // would be reaching for belongs to `Box<[T]>`, an unsized slice.
516        //
517        // Filled across several words rather than just word 0: the restored PC
518        // carries over from the tick above and is 4, so word 0 alone would
519        // fetch a zero and count nothing.
520        for w in 0..8 {
521            let a = w * 4;
522            restored.imem[a..a + 4].copy_from_slice(&word.to_be_bytes());
523        }
524        restored.sp.set_halted(false);
525        restored.tick();
526        assert_eq!(
527            restored.vu_funct_histogram()[VMUDN_FUNCT],
528            1,
529            "a restored RSP stopped counting COP2 ops"
530        );
531    }
532
533    #[test]
534    fn constructs_halted() {
535        let mut rsp = Rsp::new();
536        assert!(rsp.halted(), "SP_STATUS.halt must be set at power-on");
537        assert_eq!(rsp.pc(), 0, "and the PC starts at 0");
538
539        // Power-on values alone cannot distinguish the accessors from the
540        // vestigial fields: BOTH representations start `halted = true, pc = 0`,
541        // so the assertions above would still pass if either accessor regressed
542        // to reading the dead field. Mutate the SP registers and require the
543        // accessors to follow — which the fields, never being written, cannot do.
544        rsp.sp.write(sp::reg::STATUS, 1); // CLR_HALT
545        rsp.sp.set_pc(0x123);
546        assert!(
547            !rsp.halted(),
548            "halted() must track SP_STATUS, not the vestigial field"
549        );
550        assert_eq!(
551            rsp.pc(),
552            0x120,
553            "pc() must track SP_PC (word-aligned), not the vestigial field"
554        );
555    }
556
557    /// A halted RSP fetches nothing and asks nothing of the machine.
558    #[test]
559    fn halted_tick_is_noop() {
560        let mut rsp = Rsp::new();
561        let out = rsp.tick();
562        assert_eq!(out, su::StepResult::default());
563        assert_eq!(rsp.sp.pc(), 0);
564    }
565
566    #[test]
567    fn version_is_non_empty() {
568        assert!(!version().is_empty());
569    }
570}