Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,55 @@ All notable changes to RustyN64 are documented here. The format is based on
The next rung is `v0.2.0 "Interpreter"` — the VR4300 (see
[`to-dos/VERSION-PLAN.md`](to-dos/VERSION-PLAN.md)).

### Added — the ADR 0007 five-stage pipeline (T-11-001, second half)

`crates/rustyn64-cpu/src/pipeline.rs`. **Structure, not instructions** — the stages move latches
and account for time; decode and execute are T-11-002 onward. What is real here is the shape,
which is the part that cannot be retrofitted without rewriting every consumer.

- Four inter-stage `Latch`es (`ic_rf`, `rf_ex`, `ex_dc`, `dc_wb`), each carrying `pc`, `word`,
`occupied`, `in_delay_slot`, and `abort`. Five stages have four boundaries; the state lives on
the boundaries.
- `Pipeline::advance` runs **WB → DC → EX → RF → IC**. Each stage reads its input latch before
any upstream stage writes it, so no value moves two stages in one cycle and no double buffering
is needed — the reverse order *is* the latching.
- `Stall { cycles, cause }` with `Interlock` naming all eight documented interlocks (LDI, DCB,
DCM, ICB, ITM, MCI, **COp**, CP0I — UM Table 4-3) so a stall is always attributable in a trace.
ADR 0007's `resume_stage` is deliberately **absent** until it can be load-bearing: `advance`
always runs the full cascade today, so a stored `resume` would be read by nothing — the same
hazard `poll_irq_at_phase` was removed for (`engineering-lessons.md` §3.2).
- An abort raises a pending flush, so the instruction fetched later in the *same* cycle is a
bubble rather than a live wrong-path fetch that would escape the flush entirely.
- `stall_for(0)` is ignored rather than recorded — a zero-cycle stall would still consume a cycle
and mark it not-a-run-cycle, silently inserting a bubble *and* suppressing interrupt acceptance
on the following cycle.
- `Exception` is deliberately **not** named `Fault`: UM §4.5 defines a fault as interlocks ∪
exceptions, and only the aborting subset rides in a latch.
- `abort_from` stamps an exception into its own latch and every latch **upstream** — the
kill-younger-instructions step. Older instructions are untouched.
- Interrupts are sampled once per `PClock` in **DC** (UM Figure 4-12, §4.7.6) and accepted only
if the previous `PCycle` was a run cycle (§4.7.1). Exactly one recognition predicate exists.
- `load_interlocks` reproduces the hardware's documented **imprecision** — matching on the `rs`
*or* `rt` encoded field whether or not it is used as a source, exempting `$zero`, and not
crossing the GPR/FPR boundary. Emulating precise behaviour here would be the bug.

Seven pipeline tests, two of which are the structural guards and both **mutation-tested**:

- `a_value_advances_exactly_one_stage_per_cycle` — reversing the cascade to run forwards fails it.
- `delay_slot_flag_survives_a_multi_cycle_stall` — the Phase 1 exit criterion. Dropping the flag
in transit fails it. A global `in_delay_slot` bool passes a naive test and fails this one.
- `an_abort_survives_the_cascade` — removing the flush fails it.
- Plus stall-freezes-the-pipeline, the interrupt run-cycle gate, abort-kills-younger-only,
aborted-instructions-do-not-retire, zero-cycle-stall-is-not-a-stall, and the load-interlock
imprecision cases.

Two existing tests had premises invalidated by this change and were corrected rather than
patched around: `Cpu::tick` no longer retires an instruction per call (it takes 5 `PCycle`s to
fill the pipeline), and the scheduler's step count now derives from `cpu_cycles()` rather than
`Cpu::retired`, since retirement lags stepping by the pipeline depth and the two are no longer
interchangeable. The residue invariant's third term likewise moved to an inter-domain
(CPU vs RCP) comparison, keeping it a property of the clock rather than of the CPU.

### Changed — the ADR 0006 scheduler rework (T-11-001, first half)

The canonical master clock is now **implemented**, not just decided.
Expand Down
16 changes: 12 additions & 4 deletions crates/rustyn64-core/src/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,10 +327,13 @@ mod tests {
for seed in [0, 1, 0xDEAD_BEEF, u64::MAX] {
let mut sys = System::new(seed);
let rcp_before = sys.bus.rcp_steps_for_test();
let cpu_before = sys.cpu.retired;
// Count CPU *steps* from the derived position, not from `Cpu::retired`
// -- since ADR 0007 the CPU is a 5-stage pipeline, so retirement lags
// stepping by the pipeline depth and the two are not interchangeable.
let cpu_before = sys.cpu_cycles();
sys.run_until(PHASE_PERIOD);
assert_eq!(
sys.cpu.retired - cpu_before,
sys.cpu_cycles() - cpu_before,
3,
"3 CPU steps per 6 master ticks (seed {seed})"
);
Expand Down Expand Up @@ -395,11 +398,16 @@ mod tests {
let master = i64::try_from(s.master_ticks()).unwrap();
let cpu_pos = i64::try_from(s.cpu_cycles()).unwrap();
let rcp_pos = i64::try_from(s.rcp_cycles()).unwrap();
let retired = i64::try_from(s.cpu.retired).unwrap();
(
master - i64::try_from(CPU_DIVIDER).unwrap() * cpu_pos,
master - i64::try_from(RCP_DIVIDER).unwrap() * rcp_pos,
cpu_pos - retired,
// the two domains against each other -- catches inter-domain
// drift even if each stayed affine to master individually.
// NOT `cpu.retired`: since ADR 0007 the CPU is a 5-stage
// pipeline, so retirement lags stepping and is a CPU property
// rather than a clock one.
i64::try_from(CPU_DIVIDER).unwrap() * cpu_pos
- i64::try_from(RCP_DIVIDER).unwrap() * rcp_pos,
)
}

Expand Down
28 changes: 20 additions & 8 deletions crates/rustyn64-cpu/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@

extern crate alloc;

pub mod pipeline;

pub use pipeline::{Exception, Interlock, Latch, Pipeline, Stage};

/// Which half of a `SysAD` bus transaction is on the wire.
///
/// The VR4300 talks to the RCP over the `SysAD` bus, which multiplexes the command
Expand Down Expand Up @@ -118,6 +122,9 @@ pub struct Cpu {
pub lo: u64,
/// Program counter (virtual address).
pub pc: u64,
/// The five-stage pipeline: four inter-stage latches plus control state
/// (ADR 0007). Advanced one `PClock` per [`Cpu::tick`].
pub pipeline: Pipeline,
/// Retired-work tally: instructions retired since power-on, for the
/// golden-log differ.
///
Expand Down Expand Up @@ -149,6 +156,7 @@ impl Cpu {
hi: 0,
lo: 0,
pc: 0xBFC0_0000,
pipeline: Pipeline::new(),
retired: 0,
}
}
Expand All @@ -168,13 +176,10 @@ impl Cpu {
/// Hot path: keep allocation-free (no `Vec`/`Box` in `tick`). The `bus`
/// argument is the `&mut Bus` the scheduler hands down each step.
pub fn tick<B: Bus>(&mut self, bus: &mut B) {
// TODO(T-11-001): the five-stage pipeline (IC/RF/EX/DC/WB) as four
// inter-stage latches advanced in REVERSE order (WB -> DC -> EX -> RF ->
// IC), which is what makes the latching implicit — see ADR 0007.
// Skeleton: retire one unit of work and keep $zero pinned.
let _ = bus;
self.pipeline.advance(bus, &mut self.pc);
self.retired = self.pipeline.retired;
// $zero is architecturally hardwired; writes to it are discarded.
self.gpr[0] = 0;
self.retired = self.retired.wrapping_add(1);
}
}

Expand Down Expand Up @@ -203,12 +208,19 @@ mod tests {
assert_eq!(cpu.pc, 0xBFC0_0000);
}

/// A tick is one `PClock`, not one instruction. The pipeline is 5 stages
/// deep, so nothing retires until it has filled (UM §4.1: "at least 5
/// `PCycle`s are required to execute an instruction").
#[test]
fn tick_retires_a_unit_of_work() {
fn a_tick_is_a_pclock_not_an_instruction() {
let mut cpu = Cpu::new();
let mut bus = NullBus;
for cycle in 1..=4 {
cpu.tick(&mut bus);
assert_eq!(cpu.retired, 0, "retired on cycle {cycle}, before WB ran");
}
cpu.tick(&mut bus);
assert_eq!(cpu.retired, 1);
assert_eq!(cpu.retired, 1, "the first instruction retires on cycle 5");
}

#[test]
Expand Down
Loading