···1010use crate::Project;
1111use crate::dock::{Dock, DockPosition};
1212use crate::editor::display_list::DisplayListEditor;
1313+use crate::editor::map::MapEditor;
1314use crate::editor::todo::TodoEditor;
1415use crate::editor::{Editor, EditorId, Inspect, TileBehavior, UndoBehavior};
1516use crate::gpu::GpuState;
···191192 self.add_editor(|id| Box::new(DisplayListEditor::new(id)), None);
192193 }
193194195195+ fn add_map_editor(&mut self) {
196196+ self.add_editor(
197197+ |id| Box::new(MapEditor::new(id)),
198198+ Some(&|_id, project| {
199199+ let tree = project.map_model();
200200+201201+ // Ground plane: two green triangles at y=0
202202+ let (_ground_id, ground) = tree.create_root();
203203+ ground.set_name("Ground");
204204+ let green = (80, 140, 80);
205205+ add_triangle(
206206+ &ground,
207207+ (-300.0, 0.0, -300.0),
208208+ (-300.0, 0.0, 300.0),
209209+ (300.0, 0.0, 300.0),
210210+ green,
211211+ );
212212+ add_triangle(
213213+ &ground,
214214+ (-300.0, 0.0, -300.0),
215215+ (300.0, 0.0, 300.0),
216216+ (300.0, 0.0, -300.0),
217217+ green,
218218+ );
219219+220220+ // Colored cube: 12 triangles (2 per face), half-size = 75
221221+ let (_cube_id, cube) = tree.create_root();
222222+ cube.set_name("Cube");
223223+ let s = 75.0;
224224+225225+ // Front (z=+s): red
226226+ let c = (220, 60, 60);
227227+ add_triangle(&cube, (-s, -s, s), (s, -s, s), (s, s, s), c);
228228+ add_triangle(&cube, (-s, -s, s), (s, s, s), (-s, s, s), c);
229229+230230+ // Back (z=-s): green
231231+ let c = (60, 180, 60);
232232+ add_triangle(&cube, (s, -s, -s), (-s, -s, -s), (-s, s, -s), c);
233233+ add_triangle(&cube, (s, -s, -s), (-s, s, -s), (s, s, -s), c);
234234+235235+ // Top (y=+s): blue
236236+ let c = (60, 60, 220);
237237+ add_triangle(&cube, (-s, s, s), (s, s, s), (s, s, -s), c);
238238+ add_triangle(&cube, (-s, s, s), (s, s, -s), (-s, s, -s), c);
239239+240240+ // Bottom (y=-s): yellow
241241+ let c = (220, 220, 60);
242242+ add_triangle(&cube, (-s, -s, -s), (s, -s, -s), (s, -s, s), c);
243243+ add_triangle(&cube, (-s, -s, -s), (s, -s, s), (-s, -s, s), c);
244244+245245+ // Right (x=+s): cyan
246246+ let c = (60, 220, 220);
247247+ add_triangle(&cube, (s, -s, s), (s, -s, -s), (s, s, -s), c);
248248+ add_triangle(&cube, (s, -s, s), (s, s, -s), (s, s, s), c);
249249+250250+ // Left (x=-s): magenta
251251+ let c = (220, 60, 220);
252252+ add_triangle(&cube, (-s, -s, -s), (-s, -s, s), (-s, s, s), c);
253253+ add_triangle(&cube, (-s, -s, -s), (-s, s, s), (-s, s, -s), c);
254254+255255+ project.doc().set_next_commit_origin("meta");
256256+ project.doc().commit();
257257+ }),
258258+ );
259259+ }
260260+194261 /// Returns the [`EditorId`] for the active document, if any.
195262 fn active_editor_id(&self) -> Option<EditorId> {
196263 self.active_editor_id
···281348 if ui.button("+ Display List").clicked() {
282349 self.add_display_list_editor();
283350 }
351351+ if ui.button("+ Map").clicked() {
352352+ self.add_map_editor();
353353+ }
284354 });
285355 });
286356 }
···296366 // Center zone: bottom dock tool icons
297367 self.bottom_dock.status_bar_icons(ui);
298368299299- // Right zone: right dock tool icons (right-aligned)
369369+ // Right zone: right dock tool icons + FPS (right-aligned)
300370 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
371371+ let fps = 1.0 / ctx.input(|i| i.stable_dt).max(f32::EPSILON);
372372+ ui.weak(format!("{fps:.0} FPS"));
301373 self.right_dock.status_bar_icons(ui);
302374 });
303375 });
···406478 }
407479 });
408480 }
481481+}
482482+483483+/// Sets the position and color of a CRDT vertex accessor.
484484+fn set_vertex(v: &pm64::model::Vertex, x: f64, y: f64, z: f64, r: i64, g: i64, b: i64) {
485485+ v.set_x(x);
486486+ v.set_y(y);
487487+ v.set_z(z);
488488+ let c = v.color();
489489+ c.set_r(r);
490490+ c.set_g(g);
491491+ c.set_b(b);
492492+ c.set_a(255);
493493+}
494494+495495+/// Adds a single colored triangle to a model node.
496496+fn add_triangle(
497497+ node: &pm64::model::ModelNode,
498498+ p0: (f64, f64, f64),
499499+ p1: (f64, f64, f64),
500500+ p2: (f64, f64, f64),
501501+ color: (i64, i64, i64),
502502+) {
503503+ let tri = node.triangles().push_new();
504504+ set_vertex(&tri.v0(), p0.0, p0.1, p0.2, color.0, color.1, color.2);
505505+ set_vertex(&tri.v1(), p1.0, p1.1, p1.2, color.0, color.1, color.2);
506506+ set_vertex(&tri.v2(), p2.0, p2.1, p2.2, color.0, color.1, color.2);
409507}
410508411509impl KammyApp {
+1
crates/kammy/src/editor.rs
···55//! Editor trait, built-in editor implementations, and tile-tree dispatch.
6677pub mod display_list;
88+pub mod map;
89pub mod todo;
9101011use std::cell::Cell;
···11+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22+//
33+// SPDX-License-Identifier: AGPL-3.0-or-later
44+55+//! Integration tests for the map rendering pipeline.
66+//!
77+//! Tests the full CRDT → RSP (F3DEX2) → RDP (parallel-rdp) → pixel path.
88+99+use pm64::gbi::{CameraMatrices, NodeData, TriangleData, VertexData};
1010+1111+/// Sets up a camera looking straight down the -Z axis from (0, 0, 500).
1212+fn test_camera(aspect: f32) -> CameraMatrices {
1313+ use crate::editor::map::camera::{OrbitCamera, mat4_to_n64};
1414+1515+ let camera = OrbitCamera {
1616+ yaw: 0.0,
1717+ pitch: 0.0,
1818+ distance: 500.0,
1919+ target: [0.0, 0.0, 0.0],
2020+ };
2121+2222+ let proj = camera.projection_matrix(aspect);
2323+ let view = camera.view_matrix();
2424+2525+ CameraMatrices {
2626+ projection: mat4_to_n64(&proj),
2727+ modelview: mat4_to_n64(&view),
2828+ }
2929+}
3030+3131+/// Creates a bright red triangle large enough to cover the viewport center.
3232+fn red_triangle_nodes() -> Vec<NodeData> {
3333+ let red = |x: i16, y: i16, z: i16| VertexData {
3434+ x,
3535+ y,
3636+ z,
3737+ r: 255,
3838+ g: 0,
3939+ b: 0,
4040+ a: 255,
4141+ };
4242+4343+ vec![NodeData {
4444+ triangles: vec![TriangleData {
4545+ v0: red(-200, -200, 0),
4646+ v1: red(200, -200, 0),
4747+ v2: red(0, 200, 0),
4848+ }],
4949+ }]
5050+}
5151+5252+/// Full pipeline test: CRDT tree → RSP render → parallel-rdp scanout → pixel check.
5353+///
5454+/// Skips gracefully if no Vulkan GPU is available.
5555+#[test]
5656+fn triangle_renders_to_expected_color() {
5757+ const FB_WIDTH: u32 = 320;
5858+ const FB_HEIGHT: u32 = 240;
5959+ const FB_ORIGIN: u32 = 0x0000_0100;
6060+6161+ // Create a Project with a red triangle in the CRDT tree
6262+ let project = crate::Project::new();
6363+ let tree = project.map_model();
6464+ let (_node_id, node) = tree.create_root();
6565+ node.set_name("test");
6666+6767+ let tri = node.triangles().push_new();
6868+ tri.v0().set_x(-200.0);
6969+ tri.v0().set_y(-200.0);
7070+ tri.v0().set_z(0.0);
7171+ tri.v0().color().set_r(255);
7272+ tri.v0().color().set_g(0);
7373+ tri.v0().color().set_b(0);
7474+ tri.v0().color().set_a(255);
7575+7676+ tri.v1().set_x(200.0);
7777+ tri.v1().set_y(-200.0);
7878+ tri.v1().set_z(0.0);
7979+ tri.v1().color().set_r(255);
8080+ tri.v1().color().set_g(0);
8181+ tri.v1().color().set_b(0);
8282+ tri.v1().color().set_a(255);
8383+8484+ tri.v2().set_x(0.0);
8585+ tri.v2().set_y(200.0);
8686+ tri.v2().set_z(0.0);
8787+ tri.v2().color().set_r(255);
8888+ tri.v2().color().set_g(0);
8989+ tri.v2().color().set_b(0);
9090+ tri.v2().color().set_a(255);
9191+9292+ project.doc().commit();
9393+9494+ // Extract nodes from the CRDT and render through the RSP
9595+ let nodes = red_triangle_nodes();
9696+ let aspect = f32::from(FB_WIDTH as u16) / f32::from(FB_HEIGHT as u16);
9797+ let camera = test_camera(aspect);
9898+ let rdp_commands = pm64::render::render(&nodes, &camera);
9999+100100+ assert!(
101101+ !rdp_commands.is_empty(),
102102+ "RSP should produce RDP commands for a red triangle"
103103+ );
104104+105105+ // Create headless Vulkan context — skip test if no GPU
106106+ let Ok(ctx) = parallel_rdp::VulkanContext::new(&[], &[]) else {
107107+ eprintln!("Skipping: no Vulkan GPU available");
108108+ return;
109109+ };
110110+ let Ok(mut renderer) = parallel_rdp::Renderer::new(&ctx, 4 * 1024 * 1024, 0) else {
111111+ eprintln!("Skipping: failed to create RDP renderer");
112112+ return;
113113+ };
114114+115115+ // Configure VI registers
116116+ renderer.set_vi_register(parallel_rdp::ViRegister::Control, 0x0000_0302);
117117+ renderer.set_vi_register(parallel_rdp::ViRegister::Origin, FB_ORIGIN);
118118+ renderer.set_vi_register(parallel_rdp::ViRegister::Width, FB_WIDTH);
119119+ renderer.set_vi_register(parallel_rdp::ViRegister::VSync, 525);
120120+ renderer.set_vi_register(parallel_rdp::ViRegister::HStart, (0x006C << 16) | 0x02EC);
121121+ renderer.set_vi_register(parallel_rdp::ViRegister::VStart, (0x0025 << 16) | 0x01FF);
122122+ renderer.set_vi_register(parallel_rdp::ViRegister::XScale, FB_WIDTH * 1024 / 640);
123123+ renderer.set_vi_register(parallel_rdp::ViRegister::YScale, FB_HEIGHT * 1024 / 480);
124124+125125+ // Submit RDP commands and scanout
126126+ renderer.begin_frame();
127127+ renderer.enqueue_commands(&rdp_commands);
128128+129129+ let mut buffer = vec![0u8; 640 * 480 * 4];
130130+ let Some((w, h)) = renderer.scanout_sync(&mut buffer) else {
131131+ panic!("scanout_sync returned None — no valid output");
132132+ };
133133+134134+ assert!(w > 0 && h > 0, "scanout dimensions should be non-zero");
135135+136136+ #[expect(
137137+ clippy::cast_possible_truncation,
138138+ clippy::as_conversions,
139139+ reason = "scanout dimensions always fit in usize"
140140+ )]
141141+ let (w, h) = (w as usize, h as usize);
142142+143143+ // Check the center pixel is red-ish (RGBA8).
144144+ // The VI filtering and N64 color format conversion mean exact values vary,
145145+ // but a red vertex-colored triangle should produce clearly red pixels.
146146+ let idx = (h / 2 * w + w / 2) * 4;
147147+ let (r, g, b) = (buffer[idx], buffer[idx + 1], buffer[idx + 2]);
148148+149149+ assert!(
150150+ r > 100 && g < 100 && b < 100,
151151+ "center pixel should be red-ish, got ({r}, {g}, {b})",
152152+ );
153153+}
+12-4
crates/kammy/src/widget/rdp_viewport.rs
···81818282 /// Renders the display list and shows the result in the UI.
8383 ///
8484+ /// `display_aspect` is the intended display aspect ratio (width/height).
8585+ /// The scanout texture is stretched to fill the available UI space at
8686+ /// this ratio — necessary because non-interlaced VI modes produce
8787+ /// half-height scanouts that don't reflect the true display shape.
8888+ ///
8489 /// The closure receives the renderer's RDRAM for direct writes (textures,
8590 /// framebuffer data, etc.) before commands are submitted.
8691 ///
···9095 ui: &mut egui::Ui,
9196 gpu: Option<&mut GpuState>,
9297 display_list: &DisplayList,
9898+ display_aspect: f32,
9399 write_rdram: impl FnOnce(&mut [u8]),
94100 ) -> egui::Response {
95101 let Some(gpu) = gpu else {
···149155 // Keep texture alive until the render pass uses it
150156 self.current_texture = Some(texture);
151157152152- let (Ok(w), Ok(h)) = (u16::try_from(width), u16::try_from(height)) else {
153153- tracing::warn!("scanout dimensions too large for display: {width}x{height}");
154154- return ui.label("Scanout too large");
158158+ // Scale image to fill available UI space at the caller's display aspect ratio
159159+ let available = ui.available_size();
160160+ let size = if available.x / available.y.max(1.0) > display_aspect {
161161+ egui::vec2(available.y * display_aspect, available.y)
162162+ } else {
163163+ egui::vec2(available.x, available.x / display_aspect)
155164 };
156156- let size = egui::vec2(f32::from(w), f32::from(h));
157165158166 let Some(texture_id) = self.texture_id else {
159167 return ui.label("Texture not ready");
···11+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22+//
33+// SPDX-License-Identifier: AGPL-3.0-or-later
44+55+//! Build script that resolves the PM64 assets directory for microcode binaries.
66+77+use std::env;
88+use std::path::PathBuf;
99+1010+fn main() {
1111+ let assets_dir = env::var("PM64_ASSETS_DIR").unwrap_or_else(|_| {
1212+ // Default to ~/papermario/assets/us/
1313+ let Ok(home) = env::var("HOME") else {
1414+ panic!("HOME not set and PM64_ASSETS_DIR not provided");
1515+ };
1616+ format!("{home}/papermario/assets/us")
1717+ });
1818+1919+ let path = PathBuf::from(&assets_dir);
2020+ assert!(
2121+ path.join("gspF3DEX2kawase_fifo_text.bin").exists(),
2222+ "F3DEX2 text binary not found at {assets_dir}/gspF3DEX2kawase_fifo_text.bin. \
2323+ Set PM64_ASSETS_DIR to the directory containing PM64 microcode binaries."
2424+ );
2525+2626+ println!("cargo:rustc-env=PM64_ASSETS_DIR={assets_dir}");
2727+ println!("cargo:rerun-if-env-changed=PM64_ASSETS_DIR");
2828+}
+434
crates/pm64/src/gbi.rs
···11+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22+//
33+// SPDX-License-Identifier: AGPL-3.0-or-later
44+55+//! GBI (Graphics Binary Interface) display list reconstruction.
66+//!
77+//! Converts plain vertex/triangle data into F3DEX2-compatible display list
88+//! commands that the N64 RSP can execute.
99+1010+/// A vertex with position and color data in N64-native ranges.
1111+#[derive(Clone, Debug, PartialEq)]
1212+pub struct VertexData {
1313+ pub x: i16,
1414+ pub y: i16,
1515+ pub z: i16,
1616+ pub r: u8,
1717+ pub g: u8,
1818+ pub b: u8,
1919+ pub a: u8,
2020+}
2121+2222+/// A triangle referencing three vertices.
2323+#[derive(Clone, Debug)]
2424+pub struct TriangleData {
2525+ pub v0: VertexData,
2626+ pub v1: VertexData,
2727+ pub v2: VertexData,
2828+}
2929+3030+/// A model node's geometry in plain (non-CRDT) form.
3131+#[derive(Clone, Debug)]
3232+pub struct NodeData {
3333+ /// Triangles belonging to this node.
3434+ pub triangles: Vec<TriangleData>,
3535+}
3636+3737+/// N64 camera matrices in s15.16 fixed-point format (64 bytes each).
3838+#[derive(Clone, Debug)]
3939+pub struct CameraMatrices {
4040+ /// Projection matrix (64 bytes, s15.16 fixed-point).
4141+ pub projection: [u8; 64],
4242+ /// Modelview matrix (64 bytes, s15.16 fixed-point).
4343+ pub modelview: [u8; 64],
4444+}
4545+4646+/// Output of GBI display list reconstruction.
4747+#[derive(Clone, Debug)]
4848+pub struct GbiOutput {
4949+ /// RDP command words (each command is 2 words = 64 bits).
5050+ pub commands: Vec<u32>,
5151+ /// Packed vertex data to be placed in RDRAM.
5252+ pub vertex_data: Vec<u8>,
5353+ /// N64 `Vp` viewport struct (16 bytes, big-endian `i16` fields).
5454+ pub viewport_data: [u8; 16],
5555+}
5656+5757+// F3DEX2 command bytes
5858+const G_VTX: u32 = 0x01;
5959+const G_TRI1: u32 = 0x05;
6060+const G_TRI2: u32 = 0x06;
6161+const G_SETGEOMETRYMODE: u32 = 0xD9;
6262+const G_MTX: u32 = 0xDA;
6363+const G_ENDDL: u32 = 0xDF;
6464+const G_RDPSETOTHERMODE: u32 = 0xEF;
6565+const G_SETSCISSOR: u32 = 0xED;
6666+const G_SETCOLORIMAGE: u32 = 0xFF;
6767+const G_SETCOMBINE: u32 = 0xFC;
6868+const G_MOVEMEM: u32 = 0xDC;
6969+const G_MV_VIEWPORT: u32 = 8;
7070+const G_FILLRECT: u32 = 0xF6;
7171+const G_SETFILLCOLOR: u32 = 0xF7;
7272+const G_RDPPIPESYNC: u32 = 0xE7;
7373+const G_RDPFULLSYNC: u32 = 0xE9;
7474+7575+// Geometry mode flags
7676+const G_SHADE: u32 = 0x0000_0004;
7777+const G_SHADING_SMOOTH: u32 = 0x0020_0000;
7878+7979+// Matrix flags
8080+const G_MTX_PROJECTION: u8 = 0x04;
8181+const G_MTX_LOAD: u8 = 0x02;
8282+const G_MTX_NOPUSH: u8 = 0x00;
8383+8484+// Framebuffer dimensions
8585+const FB_WIDTH: u32 = 320;
8686+const FB_HEIGHT: u32 = 240;
8787+8888+/// Maximum vertices in a single `gSPVertex` load (F3DEX2 cache size).
8989+const MAX_VERTS_PER_BATCH: usize = 32;
9090+9191+/// Packs a single vertex into 16 bytes (N64 Vtx format).
9292+///
9393+/// Layout: `x:i16 y:i16 z:i16 flag:0 tc_s:0 tc_t:0 r:u8 g:u8 b:u8 a:u8`
9494+fn pack_vertex(v: &VertexData) -> [u8; 16] {
9595+ let mut buf = [0u8; 16];
9696+ buf[0..2].copy_from_slice(&v.x.to_be_bytes());
9797+ buf[2..4].copy_from_slice(&v.y.to_be_bytes());
9898+ buf[4..6].copy_from_slice(&v.z.to_be_bytes());
9999+ // flag[6..8] = 0, tc_s[8..10] = 0, tc_t[10..12] = 0
100100+ buf[12] = v.r;
101101+ buf[13] = v.g;
102102+ buf[14] = v.b;
103103+ buf[15] = v.a;
104104+ buf
105105+}
106106+107107+/// Converts a `usize` vertex index to `u32` for command encoding.
108108+///
109109+/// Vertex indices are always small (< 32 per batch), so truncation is not a concern.
110110+#[expect(
111111+ clippy::cast_possible_truncation,
112112+ reason = "vertex indices are always < 32"
113113+)]
114114+fn idx_u32(i: usize) -> u32 {
115115+ #[expect(clippy::as_conversions, reason = "bounded by MAX_VERTS_PER_BATCH (32)")]
116116+ let result = i as u32;
117117+ result
118118+}
119119+120120+/// Packs the N64 `Vp` viewport struct for the given framebuffer dimensions.
121121+///
122122+/// The viewport maps clip-space [-1,1] to screen pixels. Scale and translate
123123+/// are both `(dim/2) * 4` in the N64's 13.2 fixed-point format.
124124+/// Z scale/translate are set to `G_MAXZ / 2` (511).
125125+#[expect(
126126+ clippy::cast_possible_truncation,
127127+ clippy::as_conversions,
128128+ reason = "framebuffer dimensions always fit in i16"
129129+)]
130130+fn pack_viewport(width: u32, height: u32) -> [u8; 16] {
131131+ let sx = (width / 2 * 4) as i16;
132132+ let sy = (height / 2 * 4) as i16;
133133+ let sz: i16 = 511;
134134+ let mut vp = [0u8; 16];
135135+ vp[0..2].copy_from_slice(&sx.to_be_bytes());
136136+ vp[2..4].copy_from_slice(&sy.to_be_bytes());
137137+ vp[4..6].copy_from_slice(&sz.to_be_bytes());
138138+ // vp[6..8] = 0 (padding)
139139+ vp[8..10].copy_from_slice(&sx.to_be_bytes());
140140+ vp[10..12].copy_from_slice(&sy.to_be_bytes());
141141+ vp[12..14].copy_from_slice(&sz.to_be_bytes());
142142+ // vp[14..16] = 0 (padding)
143143+ vp
144144+}
145145+146146+/// Reconstructs a GBI display list from model nodes and camera matrices.
147147+///
148148+/// The output display list sets up the framebuffer, scissor, render modes,
149149+/// matrices, then draws all triangles with vertex coloring.
150150+///
151151+/// # Arguments
152152+/// - `nodes`: Model geometry to render.
153153+/// - `camera`: Camera projection and modelview matrices in N64 format.
154154+/// - `fb_addr`: RDRAM address for the framebuffer.
155155+/// - `vertex_addr`: RDRAM address where vertex data will be placed.
156156+/// - `proj_addr`: RDRAM address of the projection matrix.
157157+/// - `mv_addr`: RDRAM address of the modelview matrix.
158158+/// - `viewport_addr`: RDRAM address where the viewport struct will be placed.
159159+#[expect(
160160+ clippy::many_single_char_names,
161161+ reason = "a/b/c vertex indices are standard triangle nomenclature"
162162+)]
163163+pub fn reconstruct(
164164+ nodes: &[NodeData],
165165+ _camera: &CameraMatrices,
166166+ fb_addr: u32,
167167+ vertex_addr: u32,
168168+ proj_addr: u32,
169169+ mv_addr: u32,
170170+ viewport_addr: u32,
171171+) -> GbiOutput {
172172+ let mut commands: Vec<u32> = Vec::new();
173173+ let mut vertex_data: Vec<u8> = Vec::new();
174174+175175+ // Build the N64 Vp struct (scale + translate, each 4 × i16, big-endian)
176176+ let viewport_data = pack_viewport(FB_WIDTH, FB_HEIGHT);
177177+178178+ // SetColorImage: RGBA 16-bit, width=320
179179+ // fmt=0 (RGBA), siz=G_IM_SIZ_16b(2), width-1
180180+ commands.push((G_SETCOLORIMAGE << 24) | (2 << 19) | (FB_WIDTH - 1));
181181+ commands.push(fb_addr);
182182+183183+ // SetScissor
184184+ commands.push(G_SETSCISSOR << 24);
185185+ commands.push(((FB_WIDTH << 2) << 12) | (FB_HEIGHT << 2));
186186+187187+ // Clear the framebuffer with a dark background
188188+ commands.push(G_RDPPIPESYNC << 24);
189189+ commands.push(0);
190190+ // SetOtherMode: FILL cycle type (bits 52-53 = 3 → 0x0030_0000 in high word)
191191+ commands.push((G_RDPSETOTHERMODE << 24) | 0x0030_0000);
192192+ commands.push(0);
193193+ // SetFillColor: dark blue-gray (48, 48, 64) in RGBA5551, duplicated for 32-bit
194194+ commands.push(G_SETFILLCOLOR << 24);
195195+ commands.push(0x3191_3191);
196196+ // FillRect: full screen (0,0)-(319,239) in 10.2 fixed-point
197197+ commands.push((G_FILLRECT << 24) | ((((FB_WIDTH - 1) << 2) << 12) | ((FB_HEIGHT - 1) << 2)));
198198+ commands.push(0);
199199+200200+ // RDP pipe sync before switching to 1-cycle rendering
201201+ commands.push(G_RDPPIPESYNC << 24);
202202+ commands.push(0);
203203+204204+ // SetOtherMode: 1-cycle, texture perspective, G_RM_OPA_SURF render mode
205205+ commands.push((G_RDPSETOTHERMODE << 24) | 0x0008_0000);
206206+ commands.push(0x0F0A_4000);
207207+208208+ // SetCombine: G_CC_SHADE — output = vertex shade color
209209+ // Encoding: (0-0)*0+SHADE for both RGB and alpha, both cycles.
210210+ commands.push(G_SETCOMBINE << 24);
211211+ commands.push(0x0002_0904);
212212+213213+ // Set geometry mode: AND mask = 0 clears all bits, then OR sets shade + smooth
214214+ commands.push(G_SETGEOMETRYMODE << 24);
215215+ commands.push(G_SHADE | G_SHADING_SMOOTH);
216216+217217+ // gSPViewport — maps clip-space to screen coordinates
218218+ // gDma2p encoding: size=(16-1)>>3=1, idx=G_MV_VIEWPORT(8), ofs=0
219219+ commands.push((G_MOVEMEM << 24) | (1 << 19) | G_MV_VIEWPORT);
220220+ commands.push(viewport_addr);
221221+222222+ // Load projection matrix
223223+ // F3DEX2's gSPMatrix XORs the push flag: NOPUSH(0) ^ PUSH(1) = 1
224224+ let mtx_proj_flags = u32::from(G_MTX_PROJECTION | G_MTX_LOAD | G_MTX_NOPUSH) ^ 1;
225225+ commands.push((G_MTX << 24) | 0x0038_0000 | mtx_proj_flags);
226226+ commands.push(proj_addr);
227227+228228+ // Load modelview matrix
229229+ let mtx_mv_flags = u32::from(G_MTX_LOAD | G_MTX_NOPUSH) ^ 1;
230230+ commands.push((G_MTX << 24) | 0x0038_0000 | mtx_mv_flags);
231231+ commands.push(mv_addr);
232232+233233+ // Collect all triangles and deduplicate vertices
234234+ let all_tris: Vec<&TriangleData> = nodes.iter().flat_map(|n| &n.triangles).collect();
235235+236236+ let mut unique_verts: Vec<VertexData> = Vec::new();
237237+ let mut tri_indices: Vec<[usize; 3]> = Vec::new();
238238+239239+ for tri in &all_tris {
240240+ let mut indices = [0usize; 3];
241241+ for (vi, v) in [&tri.v0, &tri.v1, &tri.v2].iter().enumerate() {
242242+ indices[vi] = if let Some(idx) = unique_verts.iter().position(|uv| uv == *v) {
243243+ idx
244244+ } else {
245245+ unique_verts.push((*v).clone());
246246+ unique_verts.len() - 1
247247+ };
248248+ }
249249+ tri_indices.push(indices);
250250+ }
251251+252252+ // Pack vertex data
253253+ for v in &unique_verts {
254254+ vertex_data.extend_from_slice(&pack_vertex(v));
255255+ }
256256+257257+ // Emit vertex loads and triangle commands in batches of MAX_VERTS_PER_BATCH
258258+ let total_verts = unique_verts.len();
259259+ let mut batch_start = 0;
260260+261261+ while batch_start < total_verts {
262262+ let batch_end = (batch_start + MAX_VERTS_PER_BATCH).min(total_verts);
263263+ let batch_count = batch_end - batch_start;
264264+265265+ // F3DEX2 gSPVertex: word0 = (G_VTX << 24) | (n << 12) | ((v0 + n) << 1)
266266+ let n = idx_u32(batch_count);
267267+ let v0 = 0u32;
268268+ commands.push((G_VTX << 24) | (n << 12) | ((v0 + n) << 1));
269269+ commands.push(vertex_addr + idx_u32(batch_start) * 16);
270270+271271+ // Emit triangles that reference vertices in this batch
272272+ let batch_tris: Vec<&[usize; 3]> = tri_indices
273273+ .iter()
274274+ .filter(|idx| idx.iter().all(|&i| i >= batch_start && i < batch_end))
275275+ .collect();
276276+277277+ let mut ti = 0;
278278+ while ti + 1 < batch_tris.len() {
279279+ // gSP2Triangles
280280+ let t0 = batch_tris[ti];
281281+ let t1 = batch_tris[ti + 1];
282282+ let (a0, b0, c0) = (
283283+ idx_u32(t0[0] - batch_start),
284284+ idx_u32(t0[1] - batch_start),
285285+ idx_u32(t0[2] - batch_start),
286286+ );
287287+ let (a1, b1, c1) = (
288288+ idx_u32(t1[0] - batch_start),
289289+ idx_u32(t1[1] - batch_start),
290290+ idx_u32(t1[2] - batch_start),
291291+ );
292292+ commands.push((G_TRI2 << 24) | ((a0 * 2) << 16) | ((b0 * 2) << 8) | (c0 * 2));
293293+ commands.push(((a1 * 2) << 16) | ((b1 * 2) << 8) | (c1 * 2));
294294+ ti += 2;
295295+ }
296296+ if ti < batch_tris.len() {
297297+ // gSP1Triangle for the remaining triangle
298298+ let t = batch_tris[ti];
299299+ let (a, b, c) = (
300300+ idx_u32(t[0] - batch_start),
301301+ idx_u32(t[1] - batch_start),
302302+ idx_u32(t[2] - batch_start),
303303+ );
304304+ commands.push((G_TRI1 << 24) | ((a * 2) << 16) | ((b * 2) << 8) | (c * 2));
305305+ commands.push(0);
306306+ }
307307+308308+ batch_start = batch_end;
309309+ }
310310+311311+ // Full sync + End display list
312312+ commands.push(G_RDPFULLSYNC << 24);
313313+ commands.push(0);
314314+ commands.push(G_ENDDL << 24);
315315+ commands.push(0);
316316+317317+ GbiOutput {
318318+ commands,
319319+ vertex_data,
320320+ viewport_data,
321321+ }
322322+}
323323+324324+#[cfg(test)]
325325+mod tests {
326326+ #![allow(clippy::unwrap_used)]
327327+328328+ use super::*;
329329+330330+ fn red_vertex(x: i16, y: i16, z: i16) -> VertexData {
331331+ VertexData {
332332+ x,
333333+ y,
334334+ z,
335335+ r: 255,
336336+ g: 0,
337337+ b: 0,
338338+ a: 255,
339339+ }
340340+ }
341341+342342+ #[test]
343343+ fn single_triangle_reconstruction() {
344344+ let nodes = vec![NodeData {
345345+ triangles: vec![TriangleData {
346346+ v0: red_vertex(0, 0, 0),
347347+ v1: red_vertex(100, 0, 0),
348348+ v2: red_vertex(50, 100, 0),
349349+ }],
350350+ }];
351351+352352+ let camera = CameraMatrices {
353353+ projection: [0u8; 64],
354354+ modelview: [0u8; 64],
355355+ };
356356+357357+ let output = reconstruct(&nodes, &camera, 0x100, 0x1000, 0x2000, 0x2040, 0x2080);
358358+359359+ // Should have commands (setup + vertex load + 1 triangle + sync + enddl)
360360+ assert!(output.commands.len() >= 4);
361361+362362+ // Should have 3 vertices * 16 bytes = 48 bytes of vertex data
363363+ assert_eq!(output.vertex_data.len(), 48);
364364+365365+ // Verify vertex packing of first vertex
366366+ let v0_x = i16::from_be_bytes([output.vertex_data[0], output.vertex_data[1]]);
367367+ assert_eq!(v0_x, 0);
368368+ assert_eq!(output.vertex_data[12], 255); // red
369369+ assert_eq!(output.vertex_data[13], 0); // green
370370+ }
371371+372372+ #[test]
373373+ fn vertex_packing_round_trip() {
374374+ let v = VertexData {
375375+ x: -100,
376376+ y: 200,
377377+ z: -300,
378378+ r: 128,
379379+ g: 64,
380380+ b: 32,
381381+ a: 255,
382382+ };
383383+ let packed = pack_vertex(&v);
384384+385385+ let x = i16::from_be_bytes([packed[0], packed[1]]);
386386+ let y = i16::from_be_bytes([packed[2], packed[3]]);
387387+ let z = i16::from_be_bytes([packed[4], packed[5]]);
388388+ assert_eq!(x, -100);
389389+ assert_eq!(y, 200);
390390+ assert_eq!(z, -300);
391391+ assert_eq!(packed[12], 128);
392392+ assert_eq!(packed[13], 64);
393393+ assert_eq!(packed[14], 32);
394394+ assert_eq!(packed[15], 255);
395395+ }
396396+397397+ #[test]
398398+ fn many_vertices_batching() {
399399+ // Create 40 triangles with unique vertices (120 verts > 32 batch limit)
400400+ let triangles: Vec<TriangleData> = (0..40)
401401+ .map(|i| {
402402+ let base = i * 3;
403403+ TriangleData {
404404+ v0: red_vertex(base, 0, 0),
405405+ v1: red_vertex(base + 1, 0, 0),
406406+ v2: red_vertex(base + 2, 0, 0),
407407+ }
408408+ })
409409+ .collect();
410410+ let nodes = vec![NodeData { triangles }];
411411+412412+ let camera = CameraMatrices {
413413+ projection: [0u8; 64],
414414+ modelview: [0u8; 64],
415415+ };
416416+417417+ let output = reconstruct(&nodes, &camera, 0x100, 0x1000, 0x2000, 0x2040, 0x2080);
418418+419419+ // Should have 120 unique vertices = 120 * 16 = 1920 bytes
420420+ assert_eq!(output.vertex_data.len(), 1920);
421421+422422+ // Should contain multiple gSPVertex commands (at least 4 batches of 32)
423423+ let vtx_count = output
424424+ .commands
425425+ .iter()
426426+ .step_by(2)
427427+ .filter(|&&w| (w >> 24) == G_VTX)
428428+ .count();
429429+ assert!(
430430+ vtx_count >= 4,
431431+ "should have multiple vertex batches, got {vtx_count}"
432432+ );
433433+ }
434434+}
+9
crates/pm64/src/lib.rs
···11+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22+//
33+// SPDX-License-Identifier: AGPL-3.0-or-later
44+55+//! Paper Mario 64 map data structures and rendering pipeline.
66+77+pub mod gbi;
88+pub mod model;
99+pub mod render;
+139
crates/pm64/src/model.rs
···11+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22+//
33+// SPDX-License-Identifier: AGPL-3.0-or-later
44+55+//! CRDT-backed model data for PM64 map geometry.
66+//!
77+//! These structs are backed by Loro via the [`loroscope`] macro, so every
88+//! field change is a CRDT operation suitable for collaborative editing and
99+//! undo/redo.
1010+1111+use loroscope::loroscope;
1212+1313+/// An RGBA color with integer channels (0–255 range by convention).
1414+#[loroscope]
1515+#[derive(Debug)]
1616+pub struct ColorRgba {
1717+ pub r: i64,
1818+ pub g: i64,
1919+ pub b: i64,
2020+ pub a: i64,
2121+}
2222+2323+/// A vertex with 3D position and vertex color.
2424+#[loroscope]
2525+#[derive(Debug)]
2626+pub struct Vertex {
2727+ pub x: f64,
2828+ pub y: f64,
2929+ pub z: f64,
3030+ pub color: ColorRgba,
3131+}
3232+3333+/// A triangle defined by three vertices.
3434+#[loroscope]
3535+#[derive(Debug)]
3636+pub struct Triangle {
3737+ pub v0: Vertex,
3838+ pub v1: Vertex,
3939+ pub v2: Vertex,
4040+}
4141+4242+/// A named node in the model tree, containing a list of triangles.
4343+#[loroscope]
4444+#[derive(Debug)]
4545+pub struct ModelNode {
4646+ /// Display name of this model node.
4747+ pub name: String,
4848+ /// The triangles that make up this node's geometry.
4949+ pub triangles: List<Triangle>,
5050+}
5151+5252+#[cfg(test)]
5353+mod tests {
5454+ #![allow(clippy::unwrap_used, clippy::float_cmp)]
5555+5656+ use loroscope::loroscope;
5757+5858+ use super::*;
5959+6060+ #[loroscope]
6161+ struct TestRoot {
6262+ pub map_model: Tree<ModelNode>,
6363+ }
6464+6565+ #[test]
6666+ fn create_model_node_with_triangle() {
6767+ let root = TestRoot::new();
6868+ let tree = root.map_model();
6969+7070+ let (node_id, node) = tree.create_root();
7171+ node.set_name("test_node");
7272+ assert_eq!(node.name(), "test_node");
7373+7474+ let tri = node.triangles().push_new();
7575+ tri.v0().set_x(0.0);
7676+ tri.v0().set_y(0.0);
7777+ tri.v0().set_z(0.0);
7878+ tri.v0().color().set_r(255);
7979+ tri.v0().color().set_g(0);
8080+ tri.v0().color().set_b(0);
8181+ tri.v0().color().set_a(255);
8282+8383+ tri.v1().set_x(100.0);
8484+ tri.v1().set_y(0.0);
8585+ tri.v1().set_z(0.0);
8686+8787+ tri.v2().set_x(50.0);
8888+ tri.v2().set_y(100.0);
8989+ tri.v2().set_z(0.0);
9090+9191+ // Read back
9292+ let read_node = tree.get(node_id).unwrap();
9393+ assert_eq!(read_node.name(), "test_node");
9494+ assert_eq!(read_node.triangles().len(), 1);
9595+9696+ let read_tri = read_node.triangles().get(0).unwrap();
9797+ assert_eq!(read_tri.v0().x(), 0.0);
9898+ assert_eq!(read_tri.v0().color().r(), 255);
9999+ assert_eq!(read_tri.v1().x(), 100.0);
100100+ assert_eq!(read_tri.v2().y(), 100.0);
101101+ }
102102+103103+ #[test]
104104+ fn multiple_triangles_in_node() {
105105+ let root = TestRoot::new();
106106+ let tree = root.map_model();
107107+108108+ let (_id, node) = tree.create_root();
109109+ node.set_name("multi");
110110+111111+ for i in 0..5 {
112112+ let tri = node.triangles().push_new();
113113+ let val = f64::from(i) * 10.0;
114114+ tri.v0().set_x(val);
115115+ tri.v1().set_y(val);
116116+ tri.v2().set_z(val);
117117+ }
118118+119119+ assert_eq!(node.triangles().len(), 5);
120120+ assert_eq!(node.triangles().get(2).unwrap().v0().x(), 20.0);
121121+ assert_eq!(node.triangles().get(4).unwrap().v2().z(), 40.0);
122122+ }
123123+124124+ #[test]
125125+ fn tree_hierarchy() {
126126+ let root = TestRoot::new();
127127+ let tree = root.map_model();
128128+129129+ let (parent_id, parent) = tree.create_root();
130130+ parent.set_name("parent");
131131+132132+ let (child_id, child) = tree.create_child(parent_id);
133133+ child.set_name("child");
134134+135135+ assert_eq!(tree.children(parent_id).unwrap().len(), 1);
136136+ assert_eq!(tree.children(parent_id).unwrap()[0], child_id);
137137+ assert_eq!(tree.get(child_id).unwrap().name(), "child");
138138+ }
139139+}
+362
crates/pm64/src/render.rs
···11+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22+//
33+// SPDX-License-Identifier: AGPL-3.0-or-later
44+55+//! Render pipeline: runs GBI display lists through the RSP to produce RDP commands.
66+//!
77+//! Loads F3DEX2 microcode, sets up RDRAM, and runs the RSP emulator.
88+//! The output is a sequence of RDP command words ready for parallel-rdp.
99+1010+use crate::gbi::{self, CameraMatrices, GbiOutput, NodeData};
1111+1212+// Microcode binaries from the Paper Mario 64 decomp
1313+const F3DEX2_TEXT: &[u8] = include_bytes!(concat!(
1414+ env!("PM64_ASSETS_DIR"),
1515+ "/gspF3DEX2kawase_fifo_text.bin"
1616+));
1717+const F3DEX2_DATA: &[u8] = include_bytes!(concat!(
1818+ env!("PM64_ASSETS_DIR"),
1919+ "/gspF3DEX2kawase_fifo_data.bin"
2020+));
2121+const RSPBOOT: &[u8] = include_bytes!(concat!(env!("PM64_ASSETS_DIR"), "/rspboot_font.bin"));
2222+2323+// RDRAM layout — addresses used both as RSP register values (u32) and
2424+// host-side byte offsets (usize). We store them as usize and convert
2525+// to u32 when writing to the OSTask fields via `addr_u32()`.
2626+const FB_ADDR: usize = 0x0000_0100;
2727+const F3DEX2_TEXT_ADDR: usize = 0x0010_0000;
2828+const F3DEX2_DATA_ADDR: usize = 0x0010_2000;
2929+const DL_ADDR: usize = 0x0012_0000;
3030+const VERTEX_ADDR: usize = 0x0014_0000;
3131+const PROJ_MTX_ADDR: usize = 0x0016_0000;
3232+const MV_MTX_ADDR: usize = 0x0016_0040;
3333+const VIEWPORT_ADDR: usize = 0x0016_0080;
3434+const RDP_OUTPUT_ADDR: usize = 0x0018_0000;
3535+const DRAM_STACK_ADDR: usize = 0x001C_0000;
3636+3737+const RDRAM_SIZE: u32 = 4 * 1024 * 1024; // 4 MB
3838+3939+/// Converts a RDRAM address constant (usize) to u32 for the RSP.
4040+#[expect(
4141+ clippy::cast_possible_truncation,
4242+ clippy::as_conversions,
4343+ reason = "RDRAM addresses are well within u32 range"
4444+)]
4545+const fn addr_u32(addr: usize) -> u32 {
4646+ addr as u32
4747+}
4848+4949+// OSTask field offsets (MIPS o32 ABI, all fields are 4 bytes)
5050+const TASK_TYPE: usize = 0x00;
5151+const TASK_FLAGS: usize = 0x04;
5252+const TASK_UCODE_BOOT: usize = 0x08;
5353+const TASK_UCODE_BOOT_SIZE: usize = 0x0C;
5454+const TASK_UCODE: usize = 0x10;
5555+const TASK_UCODE_SIZE: usize = 0x14;
5656+const TASK_UCODE_DATA: usize = 0x18;
5757+const TASK_UCODE_DATA_SIZE: usize = 0x1C;
5858+const TASK_DRAM_STACK: usize = 0x20;
5959+const TASK_DRAM_STACK_SIZE: usize = 0x24;
6060+const TASK_OUTPUT_BUFF: usize = 0x28;
6161+const TASK_OUTPUT_BUFF_SIZE: usize = 0x2C;
6262+const TASK_DATA_PTR: usize = 0x30;
6363+const TASK_DATA_SIZE: usize = 0x34;
6464+const TASK_YIELD_DATA_PTR: usize = 0x38;
6565+6666+/// Size of the `OSTask` structure in bytes.
6767+const OS_TASK_SIZE: usize = 0x40;
6868+6969+/// DMEM offset where the `OSTask` is placed (`SP_IMEM_START` - sizeof(`OSTask`)).
7070+const TASK_DMEM_OFFSET: usize = 0x1000 - OS_TASK_SIZE;
7171+7272+/// `M_GFXTASK` — graphics task type.
7373+const M_GFXTASK: u32 = 1;
7474+7575+/// `SP_UCODE_SIZE` — rspboot loads this many bytes of text into IMEM.
7676+const SP_UCODE_SIZE: u32 = 4096;
7777+7878+/// `SP_UCODE_DATA_SIZE` — loaded into DMEM by rspboot.
7979+const SP_UCODE_DATA_SIZE: u32 = 2048;
8080+8181+/// `SP_DRAM_STACK_SIZE8` — microcode DRAM stack size.
8282+const SP_DRAM_STACK_SIZE: u32 = 1024;
8383+8484+/// Writes a big-endian u32 to a byte slice (for RSP memory: DMEM/IMEM).
8585+fn write_be_u32(buf: &mut [u8], offset: usize, value: u32) {
8686+ buf[offset..offset + 4].copy_from_slice(&value.to_be_bytes());
8787+}
8888+8989+/// Writes a u32 to RDRAM in native endian.
9090+///
9191+/// The RSP emulator stores RDRAM words in native byte order. DMA between
9292+/// RDRAM and RSP memory handles the endian conversion (native ↔ big-endian).
9393+fn write_rdram_u32(rdram: &mut [u8], offset: usize, value: u32) {
9494+ rdram[offset..offset + 4].copy_from_slice(&value.to_ne_bytes());
9595+}
9696+9797+/// Copies big-endian byte data into RDRAM, word-swapping for native storage.
9898+///
9999+/// Input is big-endian bytes (N64 native format). RDRAM stores words in
100100+/// the host's native byte order. This function converts each 4-byte word.
101101+/// Any trailing bytes (< 4) are handled as a partial word.
102102+fn write_be_bytes_to_rdram(rdram: &mut [u8], offset: usize, data: &[u8]) {
103103+ for (i, chunk) in data.chunks(4).enumerate() {
104104+ let mut padded = [0u8; 4];
105105+ padded[..chunk.len()].copy_from_slice(chunk);
106106+ let word = u32::from_be_bytes(padded);
107107+ write_rdram_u32(rdram, offset + i * 4, word);
108108+ }
109109+}
110110+111111+/// Renders model geometry through the RSP, producing RDP command words.
112112+///
113113+/// Creates a fresh RSP device each call. Prefer [`Renderer`] for repeated
114114+/// rendering (e.g. per-frame in an editor) to avoid the 4 MB RDRAM allocation
115115+/// on every call.
116116+pub fn render(nodes: &[NodeData], camera: &CameraMatrices) -> Vec<u32> {
117117+ let mut renderer = Renderer::new();
118118+ renderer.render(nodes, camera)
119119+}
120120+121121+/// Persistent RSP render context that reuses its device across frames.
122122+///
123123+/// Avoids the 4 MB RDRAM allocation that [`render`] incurs on every call.
124124+/// Microcode is loaded once at construction; subsequent [`render`](Self::render)
125125+/// calls only write the per-frame data (display list, vertices, matrices)
126126+/// and reset the RSP/RDP state.
127127+pub struct Renderer {
128128+ device: rsp::Device,
129129+}
130130+131131+impl std::fmt::Debug for Renderer {
132132+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133133+ f.debug_struct("Renderer").finish_non_exhaustive()
134134+ }
135135+}
136136+137137+impl Renderer {
138138+ /// Creates a new renderer, allocating RDRAM and loading F3DEX2 microcode.
139139+ pub fn new() -> Self {
140140+ let mut device = rsp::Device::new(RDRAM_SIZE);
141141+ let rdram = device.rdram_mut();
142142+ write_be_bytes_to_rdram(rdram, F3DEX2_TEXT_ADDR, F3DEX2_TEXT);
143143+ write_be_bytes_to_rdram(rdram, F3DEX2_DATA_ADDR, F3DEX2_DATA);
144144+ Self { device }
145145+ }
146146+147147+ /// Renders model geometry through the RSP, producing RDP command words.
148148+ pub fn render(&mut self, nodes: &[NodeData], camera: &CameraMatrices) -> Vec<u32> {
149149+ let gbi_output = gbi::reconstruct(
150150+ nodes,
151151+ camera,
152152+ addr_u32(FB_ADDR),
153153+ addr_u32(VERTEX_ADDR),
154154+ addr_u32(PROJ_MTX_ADDR),
155155+ addr_u32(MV_MTX_ADDR),
156156+ addr_u32(VIEWPORT_ADDR),
157157+ );
158158+ self.render_gbi(&gbi_output, camera)
159159+ }
160160+161161+ /// Renders a pre-built GBI display list through the RSP.
162162+ fn render_gbi(&mut self, gbi_output: &GbiOutput, camera: &CameraMatrices) -> Vec<u32> {
163163+ self.device.reset();
164164+165165+ let rdram = self.device.rdram_mut();
166166+167167+ // Write display list commands (u32 values → native-endian RDRAM)
168168+ let dl_bytes = gbi_output.commands.len() * 4;
169169+ for (i, &word) in gbi_output.commands.iter().enumerate() {
170170+ write_rdram_u32(rdram, DL_ADDR + i * 4, word);
171171+ }
172172+173173+ // Write vertex data (big-endian packed bytes → native-endian RDRAM)
174174+ write_be_bytes_to_rdram(rdram, VERTEX_ADDR, &gbi_output.vertex_data);
175175+176176+ // Write camera matrices (big-endian N64 format → native-endian RDRAM)
177177+ write_be_bytes_to_rdram(rdram, PROJ_MTX_ADDR, &camera.projection);
178178+ write_be_bytes_to_rdram(rdram, MV_MTX_ADDR, &camera.modelview);
179179+180180+ // Write viewport data (big-endian i16 values → native-endian RDRAM)
181181+ write_be_bytes_to_rdram(rdram, VIEWPORT_ADDR, &gbi_output.viewport_data);
182182+183183+ // Write rspboot to IMEM (big-endian, RSP reads directly)
184184+ self.device.imem_mut()[..RSPBOOT.len()].copy_from_slice(RSPBOOT);
185185+186186+ // Write OSTask to DMEM (big-endian, RSP reads directly with LW)
187187+ let dmem = self.device.dmem_mut();
188188+ let task_base = TASK_DMEM_OFFSET;
189189+190190+ write_be_u32(dmem, task_base + TASK_TYPE, M_GFXTASK);
191191+ write_be_u32(dmem, task_base + TASK_FLAGS, 0);
192192+ write_be_u32(dmem, task_base + TASK_UCODE_BOOT, 0);
193193+ write_be_u32(dmem, task_base + TASK_UCODE_BOOT_SIZE, 0);
194194+ write_be_u32(dmem, task_base + TASK_UCODE, addr_u32(F3DEX2_TEXT_ADDR));
195195+ write_be_u32(dmem, task_base + TASK_UCODE_SIZE, SP_UCODE_SIZE);
196196+ write_be_u32(
197197+ dmem,
198198+ task_base + TASK_UCODE_DATA,
199199+ addr_u32(F3DEX2_DATA_ADDR),
200200+ );
201201+ write_be_u32(dmem, task_base + TASK_UCODE_DATA_SIZE, SP_UCODE_DATA_SIZE);
202202+ write_be_u32(dmem, task_base + TASK_DRAM_STACK, addr_u32(DRAM_STACK_ADDR));
203203+ write_be_u32(dmem, task_base + TASK_DRAM_STACK_SIZE, SP_DRAM_STACK_SIZE);
204204+ write_be_u32(
205205+ dmem,
206206+ task_base + TASK_OUTPUT_BUFF,
207207+ addr_u32(RDP_OUTPUT_ADDR),
208208+ );
209209+ write_be_u32(
210210+ dmem,
211211+ task_base + TASK_OUTPUT_BUFF_SIZE,
212212+ addr_u32(RDP_OUTPUT_ADDR) + 0x0004_0000,
213213+ );
214214+ write_be_u32(dmem, task_base + TASK_DATA_PTR, addr_u32(DL_ADDR));
215215+ #[expect(
216216+ clippy::cast_possible_truncation,
217217+ clippy::as_conversions,
218218+ reason = "display list size fits in u32"
219219+ )]
220220+ let dl_size = dl_bytes as u32;
221221+ write_be_u32(dmem, task_base + TASK_DATA_SIZE, dl_size);
222222+ write_be_u32(dmem, task_base + TASK_YIELD_DATA_PTR, 0);
223223+224224+ // Decode IMEM and run RSP
225225+ self.device.decode_imem();
226226+ self.device.set_pc(0);
227227+ self.device.clear_halt();
228228+229229+ self.device.run().to_vec()
230230+ }
231231+}
232232+233233+#[cfg(test)]
234234+mod tests {
235235+ #![allow(clippy::unwrap_used)]
236236+237237+ use super::*;
238238+ use crate::gbi::{TriangleData, VertexData};
239239+240240+ /// Builds an N64 s15.16 identity matrix (64 bytes, big-endian).
241241+ ///
242242+ /// Format: first 32 bytes = integer halves (i16 BE), last 32 bytes = fractional halves (u16 BE).
243243+ /// Column-major: element [row][col] at position `col * 4 + row`.
244244+ fn identity_n64_matrix() -> [u8; 64] {
245245+ let mut m = [0u8; 64];
246246+ for i in 0..4 {
247247+ let offset = (i * 4 + i) * 2; // diagonal element in the integer half
248248+ m[offset] = 0x00;
249249+ m[offset + 1] = 0x01; // 1.0 as i16 BE
250250+ }
251251+ m
252252+ }
253253+254254+ #[test]
255255+ fn rsp_executes_rspboot() {
256256+ // Minimal test: rspboot + F3DEX2 with an empty display list (just EndDL)
257257+ let mut device = rsp::Device::new(RDRAM_SIZE);
258258+ let rdram = device.rdram_mut();
259259+260260+ write_be_bytes_to_rdram(rdram, F3DEX2_TEXT_ADDR, F3DEX2_TEXT);
261261+ write_be_bytes_to_rdram(rdram, F3DEX2_DATA_ADDR, F3DEX2_DATA);
262262+263263+ let dl: &[u32] = &[0xDF00_0000, 0x0000_0000];
264264+ for (i, &word) in dl.iter().enumerate() {
265265+ write_rdram_u32(rdram, DL_ADDR + i * 4, word);
266266+ }
267267+268268+ device.imem_mut()[..RSPBOOT.len()].copy_from_slice(RSPBOOT);
269269+270270+ let dmem = device.dmem_mut();
271271+ let task_base = TASK_DMEM_OFFSET;
272272+ write_be_u32(dmem, task_base + TASK_TYPE, M_GFXTASK);
273273+ write_be_u32(dmem, task_base + TASK_FLAGS, 0);
274274+ write_be_u32(dmem, task_base + TASK_UCODE_BOOT, 0);
275275+ write_be_u32(dmem, task_base + TASK_UCODE_BOOT_SIZE, 0);
276276+ write_be_u32(dmem, task_base + TASK_UCODE, addr_u32(F3DEX2_TEXT_ADDR));
277277+ write_be_u32(dmem, task_base + TASK_UCODE_SIZE, SP_UCODE_SIZE);
278278+ write_be_u32(
279279+ dmem,
280280+ task_base + TASK_UCODE_DATA,
281281+ addr_u32(F3DEX2_DATA_ADDR),
282282+ );
283283+ write_be_u32(dmem, task_base + TASK_UCODE_DATA_SIZE, SP_UCODE_DATA_SIZE);
284284+ write_be_u32(dmem, task_base + TASK_DRAM_STACK, addr_u32(DRAM_STACK_ADDR));
285285+ write_be_u32(dmem, task_base + TASK_DRAM_STACK_SIZE, SP_DRAM_STACK_SIZE);
286286+ write_be_u32(
287287+ dmem,
288288+ task_base + TASK_OUTPUT_BUFF,
289289+ addr_u32(RDP_OUTPUT_ADDR),
290290+ );
291291+ write_be_u32(
292292+ dmem,
293293+ task_base + TASK_OUTPUT_BUFF_SIZE,
294294+ addr_u32(RDP_OUTPUT_ADDR) + 0x0004_0000,
295295+ );
296296+ write_be_u32(dmem, task_base + TASK_DATA_PTR, addr_u32(DL_ADDR));
297297+ write_be_u32(dmem, task_base + TASK_DATA_SIZE, 8);
298298+ write_be_u32(dmem, task_base + TASK_YIELD_DATA_PTR, 0);
299299+300300+ device.decode_imem();
301301+ device.set_pc(0);
302302+ device.clear_halt();
303303+304304+ let rdp_command_count = device.run().len();
305305+306306+ assert!(
307307+ device.rsp.cpu.broken || device.rsp.cpu.halted,
308308+ "RSP should have terminated"
309309+ );
310310+ // An empty display list shouldn't produce many RDP commands
311311+ // (F3DEX2 may emit a few sync commands, so just check it terminates)
312312+ assert!(rdp_command_count < 100, "unexpectedly many RDP commands");
313313+ }
314314+315315+ #[test]
316316+ fn single_red_triangle_produces_rdp_commands() {
317317+ let nodes = vec![NodeData {
318318+ triangles: vec![TriangleData {
319319+ v0: VertexData {
320320+ x: 0,
321321+ y: 0,
322322+ z: 0,
323323+ r: 255,
324324+ g: 0,
325325+ b: 0,
326326+ a: 255,
327327+ },
328328+ v1: VertexData {
329329+ x: 100,
330330+ y: 0,
331331+ z: 0,
332332+ r: 255,
333333+ g: 0,
334334+ b: 0,
335335+ a: 255,
336336+ },
337337+ v2: VertexData {
338338+ x: 50,
339339+ y: 100,
340340+ z: 0,
341341+ r: 255,
342342+ g: 0,
343343+ b: 0,
344344+ a: 255,
345345+ },
346346+ }],
347347+ }];
348348+349349+ let camera = CameraMatrices {
350350+ projection: identity_n64_matrix(),
351351+ modelview: identity_n64_matrix(),
352352+ };
353353+354354+ let rdp_commands = render(&nodes, &camera);
355355+356356+ // The RSP should have produced RDP commands including triangle edge data
357357+ assert!(
358358+ !rdp_commands.is_empty(),
359359+ "RSP should produce RDP commands for a single triangle"
360360+ );
361361+ }
362362+}