A standalone N64 HLE + wgpu renderer — hand it a display list from N64 memory and it draws the frame.
fast3d walks N64 display lists through a high-level emulation of the RCP and rasterizes them with wgpu, so the same renderer runs natively and on the web (WebGPU/wasm).
Texture preparation uses GPU decode with renderer-owned residency. See texture decode and residency for ownership, limits and counters.
[dependencies]
fast3d = "1.0"Implement Hardware to expose N64 memory, then drive the frame loop:
// once
let mut renderer = fast3d::Renderer::new(window, width, height, config).await?;
// per frame
renderer.begin_frame();
renderer.process_dl(&hw, dl_entry, fast3d::Microcode::F3dex2, &mut diags);
renderer.present(&hw)?;Hardware— your bridge to guest memory.rdram()returns anRdramreader —RdramImage::new(&bytes)or your own safe reader — andvi()gives the VI registers that pick the scanout framebuffer. Native pointer graphs use the unsafe host entry below.begin_frame→process_dl→present— reset per-frame state, interpret one display list into the internal framebuffer, then scan the VI framebuffer out to the owned surface (orpresent_toa view you own).- Supported microcodes —
F3dex2andF3d. The fixed-vs-float vertex/matrix layout is an orthogonalDataFormataxis:Fixed(authentic N64, the default) orFloat(GBI_FLOATS, as PC ports like sm64/wafel emit) — select it once withRenderer::set_data_format.
Ordinary display lists share RDP registers and TMEM across tasks and begin_frame.
An established color image, including address zero, remains the target until replaced or
reset. Before an independently authored scene, call reset_rdp_state to reset registers
while preserving pixels, or reset to also discard framebuffers, retained scenes and scanout
and restart the dither sequence. ClearPolicy still controls color contents across frames.
Diagnostics stream through a DiagSink (LogSink, NopSink, or your own).
Rdram reads and address resolution return Result<_, MemoryError>.
read_bytes(address, length) returns exactly length bytes or an error;
in_bounds is advisory and overflow-safe. Errors carry the requested u64
address, byte length and a MemoryErrorKind, without a command PC or logging.
Command, RawVertex and Matrix are exported from both fast3d and
fast3d::hardware. They are decoded values, not guest layouts for casting.
Command::w1 contains numeric low bits; address operands use w1_addr.
Matrix is [[f32; 4]; 4].
IMAGE readers decode big-endian Fixed data. Segment writes store the raw low
32 bits; resolution uses bits 24–27 to select a segment and adds the low 24 bits
with intentional 32-bit wrapping. Masked resolution then applies 0x00ff_fff8.
Operands above u32::MAX are rejected. Float matrix/vertex requests are errors;
custom readers can implement their own decoded layouts.
A failed read produces DiagKind::MemoryRead { access, error } at the command
that initiated it. The first memory failure discards that task before GPU
submission, with errors > 0, tris = 0 and renderable = false. Earlier tasks
remain visible. No source memory is read during presentation.
HostRam is a safe descriptor with initial segments: [u64; 16]; it no longer
implements Rdram. Replace native Hardware adapters with:
renderer.set_data_format(fast3d::DataFormat::Fixed);
renderer.begin_frame();
let ram = fast3d::HostRam::new(&[]);
// SAFETY: the submitting guest is blocked; every reachable input stays valid until return.
let summary = unsafe {
renderer.process_dl_host(ram, entry, fast3d::Microcode::F3dex2, &mut diags)
};
// The guest can resume here.
renderer.present_last()?;Native commands are 16-byte native-endian words with full-width addresses.
Reads allow unaligned inputs; Fixed matrices use native packed words, and
Fixed/Float vertices have 16/24-byte strides. Every reachable input must be
allocated, readable, initialized in that layout and stable until consumption
returns. The lifetime witness does not establish pointer validity, and the
dispatch cap only bounds liveness. CIMG/ZIMG are GPU target identities and need
not be readable unless a command uses them as input. present_last_to(view)
scans out to a caller-owned view.
With capture, use the equivalent unsafe
CaptureFrame::process_dl_host(&mut renderer, ram, entry, microcode, data_format, &mut diags).
It owns all recorded spans before returning; present_last(&mut renderer) or
present_last_to(&mut renderer, view) and fixture serialization can run after
the guest resumes. Replay reads owned spans through the safe fallible API.
G_SETCONVERT stores six signed nine-bit coefficients (-256..255).
G_SETKEYR and G_SETKEYGB retain each channel's centre, scale and 12-bit width.
Draws snapshot these registers, including rectangles. Unused setters are silent.
These combiner inputs are recognised but remain unwired:
| Input | Colour slot / encoding | Active-draw diagnostic |
|---|---|---|
KEY_CENTER |
B / 6 | UnsupportedKeyInput { selector: KeyInput::Center } |
KEY_SCALE |
C / 6 | UnsupportedKeyInput { selector: KeyInput::Scale } |
| K4 | B / 7 | UnsupportedConvertInput { selector: ConvertInput::K4 } |
| K5 | C / 15 | UnsupportedConvertInput { selector: ConvertInput::K5 } |
One-cycle mode checks cycle 1; two-cycle mode checks both cycles. Copy and fill modes bypass the combiner. Inactive selectors do not reject a draw.
Writing TEXTCONV to a value other than G_TC_FILT rejects subsequent draws with
UnsupportedTextureConversion; enabling G_CK_KEY rejects them with
UnsupportedChromaKey. The unwritten TEXTCONV default retains the library's
existing rendering behaviour. All these diagnostics have Error severity and
drop the draw before shader preparation. Selector enums live in fast3d::diag.
use std::ops::ControlFlow;
use fast3d::{DataFormat, Microcode, RdramImage};
use fast3d::inspect::{walk, WalkObserver, WalkStep, WalkTermination};
struct Counter(u32);
impl WalkObserver for Counter {
fn command(&mut self, step: WalkStep<'_>) -> ControlFlow<()> {
assert_eq!(step.seq, self.0);
self.0 += 1;
ControlFlow::Continue(())
}
}
let bytes = [0xDF, 0, 0, 0, 0, 0, 0, 0]; // F3DEX2 ENDDL
let mut counter = Counter(0);
let summary = walk(
RdramImage::new(&bytes),
0,
Microcode::F3dex2,
DataFormat::Fixed,
&mut counter,
);
assert_eq!(counter.0, 1);
assert_eq!(summary.termination, WalkTermination::End);inspect::walk walks on the CPU without a renderer or GPU device. Callbacks borrow state after each dispatched command; copy data you retain. Continuation words share their parent step, and emissions describe HLE output. Return ControlFlow::Break(()) to cancel. Walks stop after at most 4,096 dispatches and report whether they completed, faulted, were cancelled, or reached the cap.
Use Renderer::process_dl_observed to attach the same observer to the walk that renders, returning DlSummary with its termination reason.
It has no inspection cap; cancellation skips rasterizing and retaining that DL while preserving earlier DLs, so memory-bounded collectors should stop storing steps and keep returning Continue(()).
Use inspect::walk for tools and tests without a GPU device, and process_dl_observed to correlate emissions with the rendered scene.
inspect::walk and Renderer::process_dl_prefix start each walk with default RDP registers
and empty TMEM. Prefix rendering leaves live task registers unchanged, including for
u32::MAX. It preserves the renderer's framebuffer contents and dither sequence; call
Renderer::reset before each independent inspector prefix for repeatable pixels.
debug-ui— an egui overlay showing per-frame scene and triangle counts.
fast3d/— the HLE interpreter, wgpu renderer, andRendererfacade.n64-gbi/— dependency leaf: GBI/RDP/RSP vocabulary, command encoders, libultragumath, and the literal conformance vectors. No dependencies. Consumers that produce or inspect display lists should depend on this directly rather than onfast3d.