···3434- Write unit tests for all public functions containing business logic, data transformations, or state management. They go in a `#[cfg(test)] mod tests` at the bottom of each file. GUI/rendering code does not require unit tests.
3535- Write integration tests using `egui_kittest` for GUI code. Binary crates (like `kammy`) cannot use the `tests/` directory at the crate root because there is no library target to import. Instead, use a `src/tests.rs` module gated behind `#[cfg(test)]`, with submodules in `src/tests/` organized by feature (e.g. `src/tests/undo.rs`). Shared test utilities go in `src/tests.rs`. Library crates should use the standard `tests/` directory at the crate root.
3636- Prefer module-level inner doc comments (`//!`) at the top of a file over outer doc comments (`///`) on the `mod` declaration. This keeps the documentation next to the code it describes.
3737+- Avoid just `#[expect]` or `#[allow]`ing lines. The checks are there for a reason. For example, `as` should usually be `.into()`.
37383839## Error handling
3940···4243- Use `anyhow` to attach context to errors as they bubble up the stack.
4344- Never ignore an error: either pass it on, or log it.
4445- If a problem is recoverable, use `ka_log::warn!` and recover.
4646+- UI code should never panic. Do not use `#[expect(clippy::expect_used)]` etc. in UI code - warn and have a fallback.
45474648Strive to encode code invariants and contracts in the type system as much as possible. [Parse, don't validate.](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/) If you can't enforce a contract in the type system, enforce them using `assert` and in documentation (if its part of a public API).
4749
···77use std::cell::Cell;
88use std::collections::HashMap;
991010-use eframe::egui;
1111-1210use crate::Project;
1311use crate::dock::{Dock, DockPosition};
1212+use crate::editor::display_list::DisplayListEditor;
1413use crate::editor::todo::TodoEditor;
1514use crate::editor::{Editor, EditorId, Inspect, TileBehavior, UndoBehavior};
1515+use crate::gpu::GpuState;
1616use crate::tool::ToolContext;
1717use crate::tool::assets::AssetsTool;
1818use crate::tool::hierarchy::HierarchyTool;
···115115 }
116116 }
117117118118- /// Returns the tile ID of an `Own` pane that is not the currently active
119119- /// one, or `None` if no other document pane exists.
120120- #[cfg(test)]
121121- pub fn find_other_pane(&self) -> Option<egui_tiles::TileId> {
122122- self.tree.tiles.iter().find_map(|(id, tile)| {
123123- if let egui_tiles::Tile::Pane(editor) = tile {
124124- (Some(*id) != self.active_document
125125- && matches!(
126126- self.undo_behaviors.get(&editor.id()),
127127- Some(UndoBehavior::Own { .. })
128128- ))
129129- .then_some(*id)
130130- } else {
131131- None
132132- }
133133- })
134134- }
135135-136118 /// Finds the tabs container that directly holds the given tile ID.
137119 fn find_parent_tabs(&self, tile_id: egui_tiles::TileId) -> Option<egui_tiles::TileId> {
138120 self.tree.tiles.iter().find_map(|(id, tile)| {
···144126 })
145127 }
146128147147- /// Switches focus to the pane with the given tile ID. Updates both the
148148- /// internal active-document tracking and the editor tabs' visible tab.
149149- #[cfg(test)]
150150- pub fn switch_to_pane(&mut self, tile_id: egui_tiles::TileId) {
151151- self.active_document = Some(tile_id);
152152- if let Some(egui_tiles::Tile::Pane(editor)) = self.tree.tiles.get(tile_id) {
153153- self.active_editor_id = Some(editor.id());
154154- }
155155- if let Some(parent_id) = self.find_parent_tabs(tile_id)
156156- && let Some(egui_tiles::Tile::Container(egui_tiles::Container::Tabs(tabs))) =
157157- self.tree.tiles.get_mut(parent_id)
158158- {
159159- tabs.set_active(tile_id);
160160- }
161161- }
162162-163163- fn add_editor(&mut self) {
129129+ /// Inserts a new editor into the tile tree with its own undo manager.
130130+ ///
131131+ /// If `setup_crdt` is provided, it is called after the editor ID is
132132+ /// allocated and before the undo manager is created. Use this to
133133+ /// initialise CRDT data for editors that need it.
134134+ fn add_editor(
135135+ &mut self,
136136+ make_editor: impl FnOnce(EditorId) -> Box<dyn Editor>,
137137+ setup_crdt: Option<&dyn Fn(EditorId, &Project)>,
138138+ ) {
164139 let editor_id = self.alloc_editor_id();
165140166166- // Insert editor into tree first to obtain the TileId
167167- let pane_id = self.tree.tiles.insert_pane({
168168- let editor: Box<dyn Editor> = Box::new(TodoEditor::new(editor_id));
169169- editor
170170- });
141141+ let pane_id = self.tree.tiles.insert_pane(make_editor(editor_id));
171142172172- // Create CRDT data keyed by stable editor id
173173- let key = editor_id.to_string();
174174- let data = self.project.tabs().get_or_create(&key);
175175- let _ = data.items();
176176- self.project.doc().set_next_commit_origin("meta");
177177- self.project.doc().commit();
143143+ if let Some(setup) = setup_crdt {
144144+ setup(editor_id, &self.project);
145145+ }
178146179147 // Create undo manager with mutual exclusion against all existing editors
180148 let origin = format!("e{}/", editor_id.0);
···206174 }
207175 }
208176177177+ fn add_todo_editor(&mut self) {
178178+ self.add_editor(
179179+ |id| Box::new(TodoEditor::new(id)),
180180+ Some(&|id, project| {
181181+ let key = id.to_string();
182182+ let data = project.tabs().get_or_create(&key);
183183+ let _ = data.items();
184184+ project.doc().set_next_commit_origin("meta");
185185+ project.doc().commit();
186186+ }),
187187+ );
188188+ }
189189+190190+ fn add_display_list_editor(&mut self) {
191191+ self.add_editor(|id| Box::new(DisplayListEditor::new(id)), None);
192192+ }
193193+209194 /// Returns the [`EditorId`] for the active document, if any.
210195 fn active_editor_id(&self) -> Option<EditorId> {
211196 self.active_editor_id
···290275291276 ui.separator();
292277293293- if ui.button("+ New Tab").clicked() {
294294- self.add_editor();
278278+ if ui.button("+ Todo").clicked() {
279279+ self.add_todo_editor();
280280+ }
281281+ if ui.button("+ Display List").clicked() {
282282+ self.add_display_list_editor();
295283 }
296284 });
297285 });
···358346 }
359347 }
360348361361- fn content_ui(&mut self, ctx: &egui::Context) {
349349+ fn content_ui(&mut self, ctx: &egui::Context, gpu: Option<&mut GpuState>) {
362350 egui::CentralPanel::default().show(ctx, |ui| {
363351 // Snapshot each tabs container's children and active tab so we can
364352 // detect removals after tree.ui() and activate the left neighbor.
···378366 let inspect_cell = Cell::new(self.inspect.take());
379367 let mut behavior = TileBehavior {
380368 project: &self.project,
369369+ gpu,
381370 undo_behaviors: &self.undo_behaviors,
382371 active_document: &mut self.active_document,
383372 active_editor_id: &mut self.active_editor_id,
···419408 }
420409}
421410422422-impl eframe::App for KammyApp {
423423- fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
411411+impl KammyApp {
412412+ /// Main UI update, called each frame from the winit event loop.
413413+ ///
414414+ /// `gpu` is `None` only in headless test environments.
415415+ pub fn update(&mut self, ctx: &egui::Context, gpu: Option<&mut GpuState>) {
424416 self.handle_keyboard(ctx);
425417 subsecond::call(|| self.toolbar_ui(ctx));
426418 subsecond::call(|| self.status_bar_ui(ctx));
427419 subsecond::call(|| self.render_docks(ctx));
428428- subsecond::call(|| self.content_ui(ctx));
420420+ // content_ui needs &mut GpuState which can't be moved into subsecond's FnMut
421421+ self.content_ui(ctx, gpu);
429422 }
430423}
+1-3
crates/kammy/src/dock.rs
···6677mod icon;
8899-use eframe::egui;
1010-119use crate::tool::{Tool, ToolContext};
1210use icon::DockIcon;
1311···132130mod tests {
133131 use super::*;
134132135135- use eframe::egui;
133133+ use egui;
136134137135 #[derive(Debug)]
138136 struct DummyTool {
-2
crates/kammy/src/dock/icon.rs
···4455//! A custom icon button for dock tools in the status bar.
6677-use eframe::egui;
88-97/// An icon button that shows active state via color and hover/press via
108/// translucent background fill.
119pub struct DockIcon {
+13-4
crates/kammy/src/editor.rs
···4455//! Editor trait, built-in editor implementations, and tile-tree dispatch.
6677+pub mod display_list;
78pub mod todo;
89910use std::cell::Cell;
1011use std::collections::HashMap;
1112use std::fmt;
12131313-use eframe::egui;
1414use tracing::debug;
15151616use crate::Project;
1717+use crate::gpu::GpuState;
17181819/// Trait for objects that provide property-editing UI in the Inspector panel.
1920///
···4647 /// The display title for the editor's tab.
4748 fn title(&self) -> String;
4849 /// Renders the editor UI.
4949- fn ui(&mut self, ui: &mut egui::Ui, ctx: &EditorContext);
5050+ fn ui(&mut self, ui: &mut egui::Ui, ctx: &mut EditorContext);
5051}
51525253/// Context passed to each editor during rendering.
5354pub struct EditorContext<'a> {
5455 /// The shared project data.
5556 pub project: &'a Project,
5757+ /// GPU state for registering textures and accessing the RDP context.
5858+ /// `None` in headless/test environments.
5959+ pub gpu: Option<&'a mut GpuState>,
5660 /// This editor's tile ID.
5761 pub tile_id: egui_tiles::TileId,
5862}
···8084/// handles focus tracking and auto-commit.
8185pub struct TileBehavior<'a> {
8286 pub project: &'a Project,
8787+ pub gpu: Option<&'a mut GpuState>,
8388 pub undo_behaviors: &'a HashMap<EditorId, UndoBehavior>,
8489 pub active_document: &'a mut Option<egui_tiles::TileId>,
8590 pub active_editor_id: &'a mut Option<EditorId>,
···156161 let project = self.project;
157162158163 subsecond::call(|| {
159159- let ctx = EditorContext { project, tile_id };
160160- editor.ui(ui, &ctx);
164164+ let mut ctx = EditorContext {
165165+ project,
166166+ gpu: self.gpu.as_deref_mut(),
167167+ tile_id,
168168+ };
169169+ editor.ui(ui, &mut ctx);
161170 });
162171163172 // Auto-commit under this editor's origin.
+121
crates/kammy/src/editor/display_list.rs
···11+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22+//
33+// SPDX-License-Identifier: AGPL-3.0-or-later
44+55+//! Display list editor: renders N64 display lists via parallel-rdp.
66+//!
77+//! Currently renders a solid-color test pattern to verify the full pipeline:
88+//! RDRAM write -> RDP command submit -> scanout -> wgpu texture -> egui display.
99+1010+use super::{Editor, EditorContext, EditorId};
1111+use crate::widget::rdp_viewport::{DisplayList, RdpViewport, ViConfig};
1212+1313+/// An editor that renders N64 display lists via the RDP.
1414+pub struct DisplayListEditor {
1515+ id: EditorId,
1616+ viewport: RdpViewport,
1717+ frame_count: u32,
1818+}
1919+2020+impl std::fmt::Debug for DisplayListEditor {
2121+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2222+ f.debug_struct("DisplayListEditor")
2323+ .field("id", &self.id)
2424+ .field("frame_count", &self.frame_count)
2525+ .finish_non_exhaustive()
2626+ }
2727+}
2828+2929+impl DisplayListEditor {
3030+ /// Creates a new display list editor with the given stable ID.
3131+ pub fn new(id: EditorId) -> Self {
3232+ Self {
3333+ id,
3434+ viewport: RdpViewport::new(4 * 1024 * 1024),
3535+ frame_count: 0,
3636+ }
3737+ }
3838+}
3939+4040+/// Build an RDP display list that fills the framebuffer with a solid color.
4141+///
4242+/// The color cycles through red, green, blue based on the frame counter,
4343+/// producing a simple animated test pattern.
4444+fn build_fill_rect_display_list(frame: u32) -> DisplayList {
4545+ // Framebuffer: 320x240, 16-bit (5/5/5/1)
4646+ const FB_WIDTH: u32 = 320;
4747+ const FB_HEIGHT: u32 = 240;
4848+ const FB_ORIGIN: u32 = 0x100; // Non-zero: parallel-rdp treats origin 0 as blank
4949+5050+ let phase = frame / 60 % 3;
5151+ let fill_color: u32 = match phase {
5252+ 0 => 0xF801_F801, // Red (16-bit 5551: R=31, G=0, B=0, A=1), packed twice
5353+ 1 => 0x07C1_07C1, // Green
5454+ _ => 0x003F_003F, // Blue
5555+ };
5656+5757+ // RDP commands (each command is 64 bits = 2 words)
5858+ let commands: Vec<u32> = vec![
5959+ // Set Color Image: format=RGBA, size=16-bit, width=320, address=0
6060+ // Command byte: 0x3F (Set Color Image)
6161+ // Bits: [63:56]=0x3F, [55:53]=format(0=RGBA), [52:51]=size(1=16-bit),
6262+ // [41:32]=width-1, [25:0]=address
6363+ 0x3F10_0000 | ((FB_WIDTH - 1) & 0x3FF),
6464+ FB_ORIGIN,
6565+ // Set Scissor: XH=0, YH=0, XL=320<<2, YL=240<<2
6666+ // Command byte: 0x2D
6767+ 0x2D00_0000,
6868+ ((FB_WIDTH << 2) << 12) | (FB_HEIGHT << 2),
6969+ // Set Other Modes: cycle_type=Fill
7070+ // Command byte: 0x2F, bit 55-52 = cycle type (3=Fill)
7171+ 0x2F30_0000,
7272+ 0x0000_0000,
7373+ // Set Fill Color
7474+ // Command byte: 0x37
7575+ 0x3700_0000,
7676+ fill_color,
7777+ // Fill Rectangle: covers entire framebuffer
7878+ // Command byte: 0x36
7979+ // Bits: XL=320<<2, YL=240<<2 (word 0), XH=0, YH=0 (word 1)
8080+ 0x3600_0000 | ((FB_WIDTH << 2) << 12) | (FB_HEIGHT << 2),
8181+ 0x0000_0000,
8282+ // Sync Full: wait for all rendering to complete
8383+ // Command byte: 0x29
8484+ 0x2900_0000,
8585+ 0x0000_0000,
8686+ ];
8787+8888+ let vi = ViConfig {
8989+ // Control: 16-bit color (bits 1:0 = 2), anti-alias + resample (bits 9:8 = 3)
9090+ control: 0x0000_0302,
9191+ origin: FB_ORIGIN,
9292+ width: FB_WIDTH,
9393+ v_sync: 525, // NTSC: 525 lines
9494+ h_start: (0x006C << 16) | 0x02EC, // Typical NTSC H range
9595+ v_start: (0x0025 << 16) | 0x01FF, // Typical NTSC V range
9696+ x_scale: (FB_WIDTH * 1024 / 640), // Scale to fill 640 output
9797+ y_scale: (FB_HEIGHT * 1024 / 480), // Scale to fill 480 output
9898+ };
9999+100100+ DisplayList { commands, vi }
101101+}
102102+103103+impl Editor for DisplayListEditor {
104104+ fn id(&self) -> EditorId {
105105+ self.id
106106+ }
107107+108108+ fn title(&self) -> String {
109109+ "Display List".to_owned()
110110+ }
111111+112112+ fn ui(&mut self, ui: &mut egui::Ui, ctx: &mut EditorContext) {
113113+ let display_list = build_fill_rect_display_list(self.frame_count);
114114+ self.frame_count = self.frame_count.wrapping_add(1);
115115+116116+ self.viewport
117117+ .ui(ui, ctx.gpu.as_deref_mut(), &display_list, |_| {});
118118+119119+ ui.ctx().request_repaint();
120120+ }
121121+}
···22//
33// SPDX-License-Identifier: AGPL-3.0-or-later
4455-//! Tests for undo/redo behavior across single and multiple editor tabs.
55+//! Tests for undo/redo behavior.
6677-use eframe::egui;
87use egui_kittest::kittest::Queryable;
981010-use crate::app::KammyApp;
1111-129/// Helper: type text into the todo input and click Add.
1313-fn add_todo(harness: &mut egui_kittest::Harness<'_, KammyApp>, text: &str) {
1010+fn add_todo(harness: &mut egui_kittest::Harness<'_>, text: &str) {
1411 harness
1512 .get_by_role(egui::accesskit::Role::TextInput)
1613 .click();
···2219 harness.run();
2320}
24212525-/// Helper: switch the visible tab and `active_document` to a different pane.
2626-fn switch_to_other_pane(harness: &mut egui_kittest::Harness<'_, KammyApp>) {
2727- let app = harness.state_mut();
2828- let other = app.find_other_pane().expect("should find another pane");
2929- app.switch_to_pane(other);
3030-}
3131-3222#[test]
3323fn single_tab_undo_redo() {
3424 let mut harness = super::make_harness();
···5343 "item should reappear after redo"
5444 );
5545}
5656-5757-#[test]
5858-fn undo_is_scoped_to_editor() {
5959- let mut harness = super::make_harness();
6060-6161- // Add item to editor 1
6262- add_todo(&mut harness, "from editor 1");
6363- assert!(harness.query_by_label("from editor 1").is_some());
6464-6565- // Create editor 2 and switch to it
6666- harness.get_by_label("+ New Tab").click();
6767- harness.run();
6868- switch_to_other_pane(&mut harness);
6969- harness.run();
7070-7171- // Add item to editor 2
7272- add_todo(&mut harness, "from editor 2");
7373- assert!(harness.query_by_label("from editor 2").is_some());
7474-7575- // Undo on editor 2: should remove "from editor 2" only
7676- harness.get_by_label("⟲ Undo").click();
7777- harness.run();
7878- assert!(
7979- harness.query_by_label("from editor 2").is_none(),
8080- "editor 2's item should be gone after undo"
8181- );
8282-8383- // Redo on editor 2, then switch back to editor 1 and undo there
8484- harness.get_by_label("⟳ Redo").click();
8585- harness.run();
8686-8787- switch_to_other_pane(&mut harness);
8888- harness.run();
8989-9090- // Editor 1's item should still be visible
9191- assert!(
9292- harness.query_by_label("from editor 1").is_some(),
9393- "editor 1's item should still be visible"
9494- );
9595-9696- // Undo on editor 1
9797- harness.get_by_label("⟲ Undo").click();
9898- harness.run();
9999- assert!(
100100- harness.query_by_label("from editor 1").is_none(),
101101- "editor 1's item should be gone after undo on editor 1"
102102- );
103103-104104- // Switch to editor 2 and verify its item survived
105105- switch_to_other_pane(&mut harness);
106106- harness.run();
107107- assert!(
108108- harness.query_by_label("from editor 2").is_some(),
109109- "editor 2's item should survive editor 1's undo"
110110- );
111111-}
-2
crates/kammy/src/theme.rs
···6677use std::sync::Arc;
8899-use eframe::egui;
1010-119/// A named font family for medium-weight text (headings, labels).
1210pub fn medium() -> egui::FontFamily {
1311 egui::FontFamily::Name("Medium".into())
-2
crates/kammy/src/tool.rs
···1212pub mod hierarchy;
1313pub mod inspector;
14141515-use eframe::egui;
1616-1715use crate::Project;
1816use crate::editor::{EditorId, Inspect};
1917
-2
crates/kammy/src/tool/assets.rs
···4455//! Assets tool: browse and manage project assets.
6677-use eframe::egui;
88-97use super::{Tool, ToolContext};
108119/// A file browser for project assets.
-2
crates/kammy/src/tool/hierarchy.rs
···4455//! Hierarchy tool: tree view of the document's structure.
6677-use eframe::egui;
88-97use super::{Tool, ToolContext};
108119/// A tree view showing the active document's structure.
-2
crates/kammy/src/tool/inspector.rs
···55//! Inspector tool: displays property UI provided by editors via the
66//! [`Inspect`](crate::editor::Inspect) trait.
7788-use eframe::egui;
99-108use super::{Tool, ToolContext};
1191210/// A tool that renders the current [`Inspect`](crate::editor::Inspect)
+7
crates/kammy/src/widget.rs
···11+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22+//
33+// SPDX-License-Identifier: AGPL-3.0-or-later
44+55+//! Reusable egui widgets for kammy.
66+77+pub mod rdp_viewport;
+234
crates/kammy/src/widget/rdp_viewport.rs
···11+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22+//
33+// SPDX-License-Identifier: AGPL-3.0-or-later
44+55+#![allow(unsafe_code, reason = "wgpu HAL interop for importing scanout VkImage")]
66+77+//! An egui widget that renders N64 display lists using parallel-rdp.
88+99+use crate::gpu::GpuState;
1010+1111+/// N64 Video Interface register configuration for scanout.
1212+#[derive(Debug, Clone)]
1313+pub struct ViConfig {
1414+ /// VI control register (format, anti-alias mode, etc.).
1515+ pub control: u32,
1616+ /// Framebuffer origin in RDRAM (byte offset).
1717+ pub origin: u32,
1818+ /// Framebuffer width in pixels.
1919+ pub width: u32,
2020+ /// Vertical sync period (total lines per frame).
2121+ pub v_sync: u32,
2222+ /// Horizontal video start/end (visible region).
2323+ pub h_start: u32,
2424+ /// Vertical video start/end (visible region).
2525+ pub v_start: u32,
2626+ /// Horizontal scale factor (2.10 fixed point).
2727+ pub x_scale: u32,
2828+ /// Vertical scale factor (2.10 fixed point).
2929+ pub y_scale: u32,
3030+}
3131+3232+/// A display list to be rendered by the RDP.
3333+#[derive(Debug, Clone)]
3434+pub struct DisplayList {
3535+ /// RDP command words (big-endian 32-bit).
3636+ pub commands: Vec<u32>,
3737+ /// Video Interface configuration for scanout.
3838+ pub vi: ViConfig,
3939+}
4040+4141+/// Reusable egui widget that renders N64 display lists via parallel-rdp.
4242+///
4343+/// Each instance owns its own [`parallel_rdp::Renderer`] (command processor +
4444+/// RDRAM). The widget submits display list commands, performs scanout, and
4545+/// displays the result as an egui image.
4646+///
4747+/// The renderer is created lazily on the first [`show`](Self::show) call that
4848+/// receives a GPU context.
4949+pub struct RdpViewport {
5050+ renderer: Option<parallel_rdp::Renderer>,
5151+ rdram_size: u32,
5252+ /// Registered egui texture ID (reused across frames).
5353+ texture_id: Option<egui::TextureId>,
5454+ /// The current frame's scanout texture wrapper. Kept alive so egui can
5555+ /// reference it during the render pass (which runs after `show()`).
5656+ current_texture: Option<wgpu::Texture>,
5757+}
5858+5959+impl std::fmt::Debug for RdpViewport {
6060+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6161+ f.debug_struct("RdpViewport")
6262+ .field("texture_id", &self.texture_id)
6363+ .finish_non_exhaustive()
6464+ }
6565+}
6666+6767+impl RdpViewport {
6868+ /// Creates a new viewport.
6969+ ///
7070+ /// `rdram_size` is the RDRAM capacity in bytes (typically 4 MiB). The
7171+ /// underlying renderer is created lazily when [`show`](Self::show) is
7272+ /// first called with a GPU context.
7373+ pub fn new(rdram_size: u32) -> Self {
7474+ Self {
7575+ renderer: None,
7676+ rdram_size,
7777+ texture_id: None,
7878+ current_texture: None,
7979+ }
8080+ }
8181+8282+ /// Renders the display list and shows the result in the UI.
8383+ ///
8484+ /// The closure receives the renderer's RDRAM for direct writes (textures,
8585+ /// framebuffer data, etc.) before commands are submitted.
8686+ ///
8787+ /// If `gpu` is `None` (headless/test), displays a placeholder label.
8888+ pub fn ui(
8989+ &mut self,
9090+ ui: &mut egui::Ui,
9191+ gpu: Option<&mut GpuState>,
9292+ display_list: &DisplayList,
9393+ write_rdram: impl FnOnce(&mut [u8]),
9494+ ) -> egui::Response {
9595+ let Some(gpu) = gpu else {
9696+ return ui.label("GPU not available");
9797+ };
9898+9999+ let renderer = match &mut self.renderer {
100100+ Some(r) => r,
101101+ None => match parallel_rdp::Renderer::new(&gpu.rdp_context, self.rdram_size, 0) {
102102+ Ok(r) => self.renderer.insert(r),
103103+ Err(e) => {
104104+ tracing::warn!("failed to create RDP renderer: {e:?}");
105105+ return ui.label("RDP renderer unavailable");
106106+ }
107107+ },
108108+ };
109109+110110+ write_rdram(renderer.rdram_mut());
111111+ renderer.begin_frame();
112112+ Self::set_vi_registers(renderer, &display_list.vi);
113113+ renderer.enqueue_commands(&display_list.commands);
114114+115115+ let Some((vk_image, width, height)) = renderer.scanout() else {
116116+ return ui.label("No scanout output");
117117+ };
118118+ if width == 0 || height == 0 {
119119+ return ui.label("No scanout output");
120120+ }
121121+122122+ // Ensure all GPU scanout work is complete before wgpu reads the image
123123+ renderer.flush();
124124+125125+ // SAFETY: flush() was called above, and the VkImage from scanout()
126126+ // remains valid until the wgpu::Texture is dropped (next frame at earliest).
127127+ let Some(texture) = (unsafe { import_scanout_image(&gpu.device, vk_image, width, height) })
128128+ else {
129129+ tracing::warn!("failed to import scanout VkImage into wgpu");
130130+ return ui.label("Scanout import failed");
131131+ };
132132+ let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
133133+134134+ // Register or update the egui texture binding
135135+ if let Some(id) = self.texture_id {
136136+ gpu.renderer.update_egui_texture_from_wgpu_texture(
137137+ &gpu.device,
138138+ &view,
139139+ wgpu::FilterMode::Nearest,
140140+ id,
141141+ );
142142+ } else {
143143+ let id =
144144+ gpu.renderer
145145+ .register_native_texture(&gpu.device, &view, wgpu::FilterMode::Nearest);
146146+ self.texture_id = Some(id);
147147+ }
148148+149149+ // Keep texture alive until the render pass uses it
150150+ self.current_texture = Some(texture);
151151+152152+ 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");
155155+ };
156156+ let size = egui::vec2(f32::from(w), f32::from(h));
157157+158158+ let Some(texture_id) = self.texture_id else {
159159+ return ui.label("Texture not ready");
160160+ };
161161+ ui.image(egui::load::SizedTexture::new(texture_id, size))
162162+ }
163163+164164+ fn set_vi_registers(renderer: &mut parallel_rdp::Renderer, vi: &ViConfig) {
165165+ use parallel_rdp::ViRegister;
166166+ renderer.set_vi_register(ViRegister::Control, vi.control);
167167+ renderer.set_vi_register(ViRegister::Origin, vi.origin);
168168+ renderer.set_vi_register(ViRegister::Width, vi.width);
169169+ renderer.set_vi_register(ViRegister::VSync, vi.v_sync);
170170+ renderer.set_vi_register(ViRegister::HStart, vi.h_start);
171171+ renderer.set_vi_register(ViRegister::VStart, vi.v_start);
172172+ renderer.set_vi_register(ViRegister::XScale, vi.x_scale);
173173+ renderer.set_vi_register(ViRegister::YScale, vi.y_scale);
174174+ }
175175+}
176176+177177+/// Imports a parallel-rdp scanout `VkImage` into wgpu as a texture.
178178+///
179179+/// # Safety
180180+///
181181+/// The `VkImage` must be valid and fully rendered (call `flush()` first).
182182+/// It must remain valid until the wgpu texture is dropped.
183183+unsafe fn import_scanout_image(
184184+ device: &wgpu::Device,
185185+ vk_image: ash::vk::Image,
186186+ width: u32,
187187+ height: u32,
188188+) -> Option<wgpu::Texture> {
189189+ let hal_texture = {
190190+ let hal_device = unsafe { device.as_hal::<wgpu::hal::api::Vulkan>() }?;
191191+ unsafe {
192192+ hal_device.texture_from_raw(
193193+ vk_image,
194194+ &wgpu::hal::TextureDescriptor {
195195+ label: None,
196196+ size: wgpu::Extent3d {
197197+ width,
198198+ height,
199199+ depth_or_array_layers: 1,
200200+ },
201201+ mip_level_count: 1,
202202+ sample_count: 1,
203203+ dimension: wgpu::TextureDimension::D2,
204204+ format: wgpu::TextureFormat::Rgba8Unorm,
205205+ usage: wgpu::TextureUses::RESOURCE,
206206+ memory_flags: wgpu::hal::MemoryFlags::empty(),
207207+ view_formats: vec![],
208208+ },
209209+ // No-op drop callback: Granite owns the VkImage
210210+ Some(Box::new(|| {})),
211211+ )
212212+ }
213213+ };
214214+215215+ Some(unsafe {
216216+ device.create_texture_from_hal::<wgpu::hal::api::Vulkan>(
217217+ hal_texture,
218218+ &wgpu::TextureDescriptor {
219219+ label: Some("rdp_scanout"),
220220+ size: wgpu::Extent3d {
221221+ width,
222222+ height,
223223+ depth_or_array_layers: 1,
224224+ },
225225+ mip_level_count: 1,
226226+ sample_count: 1,
227227+ dimension: wgpu::TextureDimension::D2,
228228+ format: wgpu::TextureFormat::Rgba8Unorm,
229229+ usage: wgpu::TextureUsages::TEXTURE_BINDING,
230230+ view_formats: &[],
231231+ },
232232+ )
233233+ })
234234+}
···11+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22+//
33+// SPDX-License-Identifier: AGPL-3.0-or-later
44+55+/// C bridge for parallel-rdp's Granite Vulkan context and RDP command processor.
66+///
77+/// Provides a headless Vulkan context (no WSI/window) suitable for sharing the
88+/// VkDevice with wgpu, plus per-editor RDP renderers that submit display list
99+/// commands and produce scanout images.
1010+1111+#pragma once
1212+1313+#include <stdint.h>
1414+1515+#ifdef __cplusplus
1616+extern "C" {
1717+#endif
1818+1919+// -- Logging --
2020+2121+/// Log level constants for `rdp_set_log_callback`.
2222+#define RDP_LOG_LEVEL_ERROR 0
2323+#define RDP_LOG_LEVEL_WARN 1
2424+#define RDP_LOG_LEVEL_INFO 2
2525+2626+/// Set a callback to receive log messages from parallel-rdp's Granite backend.
2727+///
2828+/// Must be called before `rdp_context_create` on the thread that will call
2929+/// the bridge functions. The callback receives a log level and a
3030+/// null-terminated message string.
3131+///
3232+/// Pass NULL to disable the callback and fall back to stderr.
3333+void rdp_set_log_callback(void (*callback)(uint32_t level, const char *msg));
3434+3535+// -- Vulkan context (Granite-owned headless device) --
3636+3737+/// Create a headless Vulkan context via Granite.
3838+///
3939+/// The caller may request additional instance/device extensions (e.g. those
4040+/// required by wgpu) which Granite will enable alongside its own requirements.
4141+///
4242+/// Returns an opaque pointer, or NULL on failure.
4343+void *rdp_context_create(
4444+ const char *const *instance_ext, uint32_t num_instance_ext,
4545+ const char *const *device_ext, uint32_t num_device_ext);
4646+4747+/// Destroy a Vulkan context created by `rdp_context_create`.
4848+void rdp_context_destroy(void *ctx);
4949+5050+/// Get the VkInstance handle from the context.
5151+void *rdp_context_get_instance(void *ctx);
5252+5353+/// Get the VkPhysicalDevice handle from the context.
5454+void *rdp_context_get_physical_device(void *ctx);
5555+5656+/// Get the VkDevice handle from the context.
5757+void *rdp_context_get_device(void *ctx);
5858+5959+/// Get a graphics/compute queue and its family index.
6060+void *rdp_context_get_queue(void *ctx, uint32_t *family_index);
6161+6262+// -- Renderer (one per editor, owns CommandProcessor + RDRAM) --
6363+6464+/// Create an RDP renderer.
6565+///
6666+/// Each renderer has its own CommandProcessor and RDRAM allocation, so
6767+/// multiple editors can render independently.
6868+///
6969+/// `rdram_size` is typically 4 or 8 MiB.
7070+/// `flags` is a bitmask of `RDP::CommandProcessorFlagBits`.
7171+void *rdp_renderer_create(void *ctx, uint32_t rdram_size, uint32_t flags);
7272+7373+/// Destroy an RDP renderer.
7474+void rdp_renderer_destroy(void *renderer);
7575+7676+/// Get a mutable pointer to the renderer's RDRAM.
7777+uint8_t *rdp_renderer_get_rdram(void *renderer);
7878+7979+/// Get the RDRAM size in bytes.
8080+uint32_t rdp_renderer_get_rdram_size(void *renderer);
8181+8282+/// Begin a new frame context.
8383+///
8484+/// Flushes pending work, drains the command ring, and advances Granite's
8585+/// per-frame resource tracking. Must be called once per frame before
8686+/// enqueuing commands.
8787+void rdp_renderer_begin_frame(void *renderer);
8888+8989+/// Enqueue RDP commands for processing.
9090+///
9191+/// `words` points to an array of 32-bit words (big-endian command data).
9292+/// `num_words` is the total number of words.
9393+void rdp_renderer_enqueue(void *renderer, const uint32_t *words, uint32_t num_words);
9494+9595+/// Set a VI (Video Interface) register.
9696+///
9797+/// `reg` is the VIRegister index (0 = Control, 1 = Origin, etc.)
9898+void rdp_renderer_set_vi_register(void *renderer, uint32_t reg, uint32_t value);
9999+100100+/// Perform scanout: read the framebuffer via the Video Interface and produce
101101+/// an output image.
102102+///
103103+/// Returns the VkImage handle for the scanout result. The image format is
104104+/// R8G8B8A8_UNORM (or SRGB depending on Granite configuration).
105105+/// `width` and `height` are set to the scanout dimensions.
106106+///
107107+/// Returns NULL if scanout produced no valid image.
108108+void *rdp_renderer_scanout(void *renderer, uint32_t *width, uint32_t *height);
109109+110110+/// Perform scanout and copy the result to a CPU buffer as RGBA8 pixels.
111111+///
112112+/// `buffer` must point to at least `width * height * 4` bytes.
113113+/// `width` and `height` are outputs set to the scanout dimensions.
114114+/// Returns 1 on success, 0 if scanout produced no valid image.
115115+int rdp_renderer_scanout_sync(
116116+ void *renderer,
117117+ uint8_t *buffer, uint32_t buffer_size,
118118+ uint32_t *width, uint32_t *height);
119119+120120+/// Signal the renderer's timeline and wait for all previous work to complete.
121121+void rdp_renderer_flush(void *renderer);
122122+123123+#ifdef __cplusplus
124124+}
125125+#endif
+18
crates/parallel_rdp/src/ffi.rs
···11+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22+//
33+// SPDX-License-Identifier: AGPL-3.0-or-later
44+55+//! Raw FFI bindings generated by bindgen from `bridge.hpp`.
66+77+#![allow(
88+ unsafe_code,
99+ non_upper_case_globals,
1010+ non_camel_case_types,
1111+ non_snake_case,
1212+ dead_code,
1313+ clippy::unreadable_literal,
1414+ clippy::doc_markdown,
1515+ missing_docs
1616+)]
1717+1818+include!(concat!(env!("OUT_DIR"), "/ffi.rs"));
+481
crates/parallel_rdp/src/lib.rs
···11+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22+//
33+// SPDX-License-Identifier: AGPL-3.0-or-later
44+55+#![allow(unsafe_code)]
66+77+//! Safe Rust wrapper around parallel-rdp, providing headless N64 RDP rendering
88+//! via Granite's Vulkan backend.
99+//!
1010+//! # Architecture
1111+//!
1212+//! A single [`VulkanContext`] creates and owns the Vulkan instance and device
1313+//! (via Granite). Multiple [`Renderer`]s can be created from the same context,
1414+//! each with independent RDRAM and command processors. This allows multiple
1515+//! renders simultaneously.
1616+//!
1717+//! The raw Vulkan handles exposed by [`VulkanContext`] can be wrapped by wgpu
1818+//! (via `from_hal`) to share the same device for both RDP compute and egui
1919+//! rendering.
2020+2121+mod ffi;
2222+2323+use std::ffi::{CStr, c_void};
2424+use std::sync::Once;
2525+2626+use ash::vk::Handle;
2727+2828+/// Convert an opaque `*mut c_void` Vulkan handle to the `u64` representation
2929+/// expected by ash's `Handle::from_raw`.
3030+///
3131+/// `usize` → `u64` has no `From` impl (not lossless on hypothetical >64-bit
3232+/// platforms), but Vulkan only exists on 32/64-bit where this is a
3333+/// zero-extension.
3434+fn ptr_to_handle(ptr: *mut c_void) -> u64 {
3535+ #[expect(
3636+ clippy::as_conversions,
3737+ reason = "no From<usize> for u64; Vulkan targets are 32/64-bit"
3838+ )]
3939+ let handle = ptr.addr() as u64;
4040+ handle
4141+}
4242+4343+/// Errors from parallel-rdp operations.
4444+#[derive(Debug, thiserror::Error)]
4545+pub enum Error {
4646+ /// Vulkan context creation failed (device not supported, extensions
4747+ /// unavailable, etc.).
4848+ #[error("failed to create Vulkan context")]
4949+ ContextCreation,
5050+5151+ /// Renderer creation failed (device not supported for RDP).
5252+ #[error("failed to create RDP renderer")]
5353+ RendererCreation,
5454+}
5555+5656+/// C callback that routes Granite log messages to `tracing`.
5757+extern "C" fn granite_log_callback(level: u32, msg: *const std::ffi::c_char) {
5858+ let msg = unsafe { CStr::from_ptr(msg) }.to_string_lossy();
5959+ match level {
6060+ 0 => tracing::error!(target: "parallel_rdp", "{msg}"),
6161+ 1 => tracing::warn!(target: "parallel_rdp", "{msg}"),
6262+ _ => tracing::info!(target: "parallel_rdp", "{msg}"),
6363+ }
6464+}
6565+6666+static LOG_INIT: Once = Once::new();
6767+6868+/// Install the Granite → tracing log bridge (idempotent).
6969+fn init_logging() {
7070+ LOG_INIT.call_once(|| unsafe {
7171+ ffi::rdp_set_log_callback(Some(granite_log_callback));
7272+ });
7373+}
7474+7575+/// A headless Vulkan context created by Granite.
7676+///
7777+/// Owns the `VkInstance`, `VkPhysicalDevice`, and `VkDevice`. The raw handles
7878+/// are accessible for wrapping by wgpu via `from_hal`.
7979+///
8080+/// # Safety
8181+///
8282+/// The Vulkan handles returned by accessor methods are valid for the lifetime
8383+/// of this struct. Do not destroy them externally.
8484+pub struct VulkanContext {
8585+ ptr: *mut c_void,
8686+}
8787+8888+// SAFETY: The underlying Granite Context/Device are internally synchronized.
8989+unsafe impl Send for VulkanContext {}
9090+// SAFETY: All accessor methods return opaque handles; mutation goes through
9191+// Renderer which has &mut self.
9292+unsafe impl Sync for VulkanContext {}
9393+9494+impl std::fmt::Debug for VulkanContext {
9595+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9696+ f.debug_struct("VulkanContext")
9797+ .field("ptr", &self.ptr)
9898+ .finish()
9999+ }
100100+}
101101+102102+impl VulkanContext {
103103+ /// Creates a new headless Vulkan context.
104104+ ///
105105+ /// `instance_extensions` and `device_extensions` are additional Vulkan
106106+ /// extensions to enable (e.g. those required by wgpu). Granite will enable
107107+ /// its own required extensions in addition to these.
108108+ ///
109109+ /// # Errors
110110+ ///
111111+ /// Returns [`Error::ContextCreation`] if Vulkan initialization fails.
112112+ pub fn new(instance_extensions: &[&CStr], device_extensions: &[&CStr]) -> Result<Self, Error> {
113113+ init_logging();
114114+115115+ let inst_ptrs: Vec<*const i8> = instance_extensions.iter().map(|s| s.as_ptr()).collect();
116116+ let dev_ptrs: Vec<*const i8> = device_extensions.iter().map(|s| s.as_ptr()).collect();
117117+118118+ let ptr = unsafe {
119119+ ffi::rdp_context_create(
120120+ inst_ptrs.as_ptr().cast(),
121121+ u32::try_from(inst_ptrs.len()).unwrap_or(u32::MAX),
122122+ dev_ptrs.as_ptr().cast(),
123123+ u32::try_from(dev_ptrs.len()).unwrap_or(u32::MAX),
124124+ )
125125+ };
126126+127127+ if ptr.is_null() {
128128+ return Err(Error::ContextCreation);
129129+ }
130130+131131+ Ok(Self { ptr })
132132+ }
133133+134134+ /// Returns the raw `VkInstance` handle.
135135+ pub fn vk_instance(&self) -> ash::vk::Instance {
136136+ let raw = unsafe { ffi::rdp_context_get_instance(self.ptr) };
137137+ ash::vk::Instance::from_raw(ptr_to_handle(raw))
138138+ }
139139+140140+ /// Returns the raw `VkPhysicalDevice` handle.
141141+ pub fn vk_physical_device(&self) -> ash::vk::PhysicalDevice {
142142+ let raw = unsafe { ffi::rdp_context_get_physical_device(self.ptr) };
143143+ ash::vk::PhysicalDevice::from_raw(ptr_to_handle(raw))
144144+ }
145145+146146+ /// Returns the raw `VkDevice` handle.
147147+ pub fn vk_device(&self) -> ash::vk::Device {
148148+ let raw = unsafe { ffi::rdp_context_get_device(self.ptr) };
149149+ ash::vk::Device::from_raw(ptr_to_handle(raw))
150150+ }
151151+152152+ /// Returns the graphics queue handle and its family index.
153153+ pub fn vk_queue(&self) -> (ash::vk::Queue, u32) {
154154+ let mut family_index = 0u32;
155155+ let raw =
156156+ unsafe { ffi::rdp_context_get_queue(self.ptr, std::ptr::addr_of_mut!(family_index)) };
157157+ (ash::vk::Queue::from_raw(ptr_to_handle(raw)), family_index)
158158+ }
159159+}
160160+161161+impl Drop for VulkanContext {
162162+ fn drop(&mut self) {
163163+ unsafe {
164164+ ffi::rdp_context_destroy(self.ptr);
165165+ }
166166+ }
167167+}
168168+169169+/// An RDP renderer with its own RDRAM and command processor.
170170+///
171171+/// Each renderer is independent — multiple renderers can exist simultaneously
172172+/// for different display list editors, all sharing the same [`VulkanContext`].
173173+pub struct Renderer {
174174+ ptr: *mut c_void,
175175+}
176176+177177+// SAFETY: The CommandProcessor is internally synchronized.
178178+unsafe impl Send for Renderer {}
179179+180180+impl std::fmt::Debug for Renderer {
181181+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182182+ f.debug_struct("Renderer").field("ptr", &self.ptr).finish()
183183+ }
184184+}
185185+186186+/// Flags for [`Renderer`] creation.
187187+///
188188+/// These correspond to `RDP::CommandProcessorFlagBits`.
189189+pub mod flags {
190190+ /// Make hidden RDRAM host-visible for debugging.
191191+ pub const HOST_VISIBLE_HIDDEN_RDRAM: u32 = 1 << 0;
192192+ /// Make TMEM host-visible for debugging.
193193+ pub const HOST_VISIBLE_TMEM: u32 = 1 << 1;
194194+ /// Enable 2x upscaling.
195195+ pub const UPSCALING_2X: u32 = 1 << 2;
196196+ /// Enable 4x upscaling.
197197+ pub const UPSCALING_4X: u32 = 1 << 3;
198198+ /// Enable 8x upscaling.
199199+ pub const UPSCALING_8X: u32 = 1 << 4;
200200+ /// Super-sampled readback.
201201+ pub const SUPER_SAMPLED_READ_BACK: u32 = 1 << 5;
202202+ /// Super-sampled dithering (improves upscaled dither patterns).
203203+ pub const SUPER_SAMPLED_DITHER: u32 = 1 << 6;
204204+}
205205+206206+/// N64 Video Interface register indices.
207207+///
208208+/// These correspond to `RDP::VIRegister`.
209209+#[repr(u32)]
210210+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211211+pub enum ViRegister {
212212+ /// VI status/control register.
213213+ Control = 0,
214214+ /// Framebuffer origin in RDRAM.
215215+ Origin = 1,
216216+ /// Framebuffer width in pixels.
217217+ Width = 2,
218218+ /// Vertical interrupt line.
219219+ Intr = 3,
220220+ /// Current vertical scanline.
221221+ VCurrentLine = 4,
222222+ /// Video timing.
223223+ Timing = 5,
224224+ /// Vertical sync period.
225225+ VSync = 6,
226226+ /// Horizontal sync period.
227227+ HSync = 7,
228228+ /// Leap pattern.
229229+ Leap = 8,
230230+ /// Horizontal video start/end.
231231+ HStart = 9,
232232+ /// Vertical video start/end.
233233+ VStart = 10,
234234+ /// Vertical burst.
235235+ VBurst = 11,
236236+ /// Horizontal scale factor.
237237+ XScale = 12,
238238+ /// Vertical scale factor.
239239+ YScale = 13,
240240+}
241241+242242+impl From<ViRegister> for u32 {
243243+ #[expect(
244244+ clippy::as_conversions,
245245+ reason = "only way to extract a #[repr(u32)] discriminant"
246246+ )]
247247+ fn from(reg: ViRegister) -> Self {
248248+ reg as Self
249249+ }
250250+}
251251+252252+impl Renderer {
253253+ /// Creates a new RDP renderer.
254254+ ///
255255+ /// `rdram_size` is the RDRAM capacity in bytes (typically 4 or 8 MiB).
256256+ /// `flags` is a bitmask of values from [`flags`].
257257+ ///
258258+ /// # Errors
259259+ ///
260260+ /// Returns [`Error::RendererCreation`] if the GPU doesn't support the RDP
261261+ /// compute shaders.
262262+ pub fn new(ctx: &VulkanContext, rdram_size: u32, flags: u32) -> Result<Self, Error> {
263263+ let ptr = unsafe { ffi::rdp_renderer_create(ctx.ptr, rdram_size, flags) };
264264+ if ptr.is_null() {
265265+ return Err(Error::RendererCreation);
266266+ }
267267+ Ok(Self { ptr })
268268+ }
269269+270270+ /// Returns a mutable slice over the renderer's RDRAM.
271271+ ///
272272+ /// Write display list data, textures, and framebuffer contents here before
273273+ /// calling [`enqueue_commands`](Self::enqueue_commands) and [`scanout`](Self::scanout).
274274+ pub fn rdram_mut(&mut self) -> &mut [u8] {
275275+ unsafe {
276276+ let ptr = ffi::rdp_renderer_get_rdram(self.ptr);
277277+ let size = ffi::rdp_renderer_get_rdram_size(self.ptr);
278278+ std::slice::from_raw_parts_mut(
279279+ ptr,
280280+ size.try_into()
281281+ .expect("RDRAM size exceeds addressable memory"),
282282+ )
283283+ }
284284+ }
285285+286286+ /// Begins a new frame context.
287287+ ///
288288+ /// Flushes pending work, drains the command ring, and advances Granite's
289289+ /// per-frame resource tracking. Must be called once per frame before
290290+ /// enqueuing commands.
291291+ pub fn begin_frame(&mut self) {
292292+ unsafe {
293293+ ffi::rdp_renderer_begin_frame(self.ptr);
294294+ }
295295+ }
296296+297297+ /// Enqueues RDP commands for processing.
298298+ ///
299299+ /// `commands` is an array of 32-bit words containing RDP command data.
300300+ pub fn enqueue_commands(&mut self, commands: &[u32]) {
301301+ unsafe {
302302+ ffi::rdp_renderer_enqueue(
303303+ self.ptr,
304304+ commands.as_ptr(),
305305+ u32::try_from(commands.len()).unwrap_or(u32::MAX),
306306+ );
307307+ }
308308+ }
309309+310310+ /// Sets a Video Interface register.
311311+ pub fn set_vi_register(&mut self, reg: ViRegister, value: u32) {
312312+ unsafe {
313313+ ffi::rdp_renderer_set_vi_register(self.ptr, reg.into(), value);
314314+ }
315315+ }
316316+317317+ /// Performs scanout and returns the resulting image.
318318+ ///
319319+ /// Returns the raw `VkImage` handle along with its width and height.
320320+ /// Returns `None` if scanout produced no valid image (e.g. blank VI config).
321321+ ///
322322+ /// The returned `VkImage` is valid until the next call to `scanout` on this
323323+ /// renderer. Its format is `R8G8B8A8_UNORM`.
324324+ pub fn scanout(&mut self) -> Option<(ash::vk::Image, u32, u32)> {
325325+ let mut width = 0u32;
326326+ let mut height = 0u32;
327327+ let raw = unsafe {
328328+ ffi::rdp_renderer_scanout(
329329+ self.ptr,
330330+ std::ptr::addr_of_mut!(width),
331331+ std::ptr::addr_of_mut!(height),
332332+ )
333333+ };
334334+ if raw.is_null() {
335335+ return None;
336336+ }
337337+ Some((ash::vk::Image::from_raw(ptr_to_handle(raw)), width, height))
338338+ }
339339+340340+ /// Performs scanout and copies the RGBA8 pixel data to `buffer`.
341341+ ///
342342+ /// Returns `Some((width, height))` on success, or `None` if scanout
343343+ /// produced no valid image. The buffer must be large enough for the
344344+ /// scanout dimensions (`width * height * 4` bytes). If the buffer is
345345+ /// too small, returns `None`.
346346+ pub fn scanout_sync(&mut self, buffer: &mut [u8]) -> Option<(u32, u32)> {
347347+ let mut width = 0u32;
348348+ let mut height = 0u32;
349349+ let ok = unsafe {
350350+ ffi::rdp_renderer_scanout_sync(
351351+ self.ptr,
352352+ buffer.as_mut_ptr(),
353353+ u32::try_from(buffer.len()).unwrap_or(u32::MAX),
354354+ std::ptr::addr_of_mut!(width),
355355+ std::ptr::addr_of_mut!(height),
356356+ )
357357+ };
358358+ if ok != 0 { Some((width, height)) } else { None }
359359+ }
360360+361361+ /// Flushes all pending work and waits for completion.
362362+ pub fn flush(&mut self) {
363363+ unsafe {
364364+ ffi::rdp_renderer_flush(self.ptr);
365365+ }
366366+ }
367367+}
368368+369369+impl Drop for Renderer {
370370+ fn drop(&mut self) {
371371+ unsafe {
372372+ ffi::rdp_renderer_destroy(self.ptr);
373373+ }
374374+ }
375375+}
376376+377377+#[cfg(test)]
378378+mod tests {
379379+ use super::*;
380380+381381+ #[test]
382382+ fn context_creation() {
383383+ // This test requires a Vulkan-capable GPU.
384384+ // It verifies that the basic FFI plumbing works.
385385+ let ctx = VulkanContext::new(&[], &[]);
386386+ if let Ok(ctx) = ctx {
387387+ assert_ne!(ctx.vk_instance(), ash::vk::Instance::null());
388388+ assert_ne!(ctx.vk_physical_device(), ash::vk::PhysicalDevice::null());
389389+ assert_ne!(ctx.vk_device(), ash::vk::Device::null());
390390+391391+ let (queue, _family) = ctx.vk_queue();
392392+ assert_ne!(queue, ash::vk::Queue::null());
393393+ }
394394+ // If no GPU is available, the test passes silently
395395+ }
396396+397397+ #[test]
398398+ fn renderer_creation() {
399399+ let ctx = VulkanContext::new(&[], &[]);
400400+ let Ok(ctx) = ctx else { return };
401401+402402+ let renderer = Renderer::new(&ctx, 4 * 1024 * 1024, 0);
403403+ if let Ok(mut renderer) = renderer {
404404+ let rdram = renderer.rdram_mut();
405405+ assert_eq!(rdram.len(), 4 * 1024 * 1024);
406406+ }
407407+ }
408408+409409+ /// Submits a fill-rect display list that fills a 320x240 16-bit
410410+ /// framebuffer with solid red, then verifies the scanout pixels.
411411+ #[test]
412412+ fn fill_rect_scanout() {
413413+ let ctx = VulkanContext::new(&[], &[]);
414414+ let Ok(ctx) = ctx else { return };
415415+416416+ let Ok(mut renderer) = Renderer::new(&ctx, 4 * 1024 * 1024, 0) else {
417417+ return;
418418+ };
419419+420420+ const FB_WIDTH: u32 = 320;
421421+ const FB_HEIGHT: u32 = 240;
422422+ const FB_ORIGIN: u32 = 0x100; // Non-zero: parallel-rdp treats origin 0 as blank
423423+424424+ // Red in 16-bit 5/5/5/1 format, packed twice for fill color register
425425+ let fill_color: u32 = 0xF801_F801;
426426+427427+ let commands: Vec<u32> = vec![
428428+ // Set Color Image: RGBA 16-bit, width=320, address=FB_ORIGIN
429429+ 0x3F10_0000 | ((FB_WIDTH - 1) & 0x3FF),
430430+ FB_ORIGIN,
431431+ // Set Scissor: (0,0) to (320,240) in 10.2 fixed point
432432+ 0x2D00_0000,
433433+ ((FB_WIDTH << 2) << 12) | (FB_HEIGHT << 2),
434434+ // Set Other Modes: cycle_type=Fill (bits [21:20] = 11)
435435+ 0x2F30_0000,
436436+ 0x0000_0000,
437437+ // Set Fill Color
438438+ 0x3700_0000,
439439+ fill_color,
440440+ // Fill Rectangle: (0,0) to (320,240) in 10.2 fixed point
441441+ 0x3600_0000 | ((FB_WIDTH << 2) << 12) | (FB_HEIGHT << 2),
442442+ 0x0000_0000,
443443+ // Sync Full
444444+ 0x2900_0000,
445445+ 0x0000_0000,
446446+ ];
447447+448448+ // VI registers for 320x240 16-bit NTSC output
449449+ renderer.set_vi_register(ViRegister::Control, 0x0000_0302);
450450+ renderer.set_vi_register(ViRegister::Origin, FB_ORIGIN);
451451+ renderer.set_vi_register(ViRegister::Width, FB_WIDTH);
452452+ renderer.set_vi_register(ViRegister::VSync, 525);
453453+ renderer.set_vi_register(ViRegister::HStart, (0x006C << 16) | 0x02EC);
454454+ renderer.set_vi_register(ViRegister::VStart, (0x0025 << 16) | 0x01FF);
455455+ renderer.set_vi_register(ViRegister::XScale, FB_WIDTH * 1024 / 640);
456456+ renderer.set_vi_register(ViRegister::YScale, FB_HEIGHT * 1024 / 480);
457457+458458+ renderer.begin_frame();
459459+ renderer.enqueue_commands(&commands);
460460+461461+ let mut buffer = vec![0u8; 640 * 480 * 4];
462462+ let Some((w, h)) = renderer.scanout_sync(&mut buffer) else {
463463+ panic!("scanout_sync returned None — no valid output");
464464+ };
465465+466466+ assert!(w > 0 && h > 0, "scanout dimensions should be non-zero");
467467+468468+ // Check the center pixel is red (RGBA8).
469469+ // The VI applies filtering so we check approximate values — 5-bit
470470+ // red (31) scales to ~248 in 8-bit.
471471+ let w: usize = w.try_into().unwrap();
472472+ let h: usize = h.try_into().unwrap();
473473+ let idx = (h / 2 * w + w / 2) * 4;
474474+ let (r, g, b) = (buffer[idx], buffer[idx + 1], buffer[idx + 2]);
475475+476476+ assert!(
477477+ r > 200 && g < 50 && b < 50,
478478+ "center pixel should be red, got ({r}, {g}, {b})",
479479+ );
480480+ }
481481+}
+36
crates/parallel_rdp/src/logging.cpp
···11+// SPDX-FileCopyrightText: 2017-2026 Hans-Kristian Arntzen
22+// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
33+//
44+// SPDX-License-Identifier: MIT
55+//
66+// Replacement for Granite's util/logging.cpp that uses a global (not
77+// thread-local) LoggingInterface, so the command ring worker thread's
88+// messages are also routed through the Rust tracing callback.
99+1010+#include "logging.hpp"
1111+#include <atomic>
1212+1313+namespace Util
1414+{
1515+1616+static std::atomic<LoggingInterface *> logging_iface{nullptr};
1717+1818+bool interface_log(const char *tag, const char *fmt, ...)
1919+{
2020+ auto *iface = logging_iface.load(std::memory_order_acquire);
2121+ if (!iface)
2222+ return false;
2323+2424+ va_list va;
2525+ va_start(va, fmt);
2626+ bool ret = iface->log(tag, fmt, va);
2727+ va_end(va);
2828+ return ret;
2929+}
3030+3131+void set_thread_logging_interface(LoggingInterface *iface)
3232+{
3333+ logging_iface.store(iface, std::memory_order_release);
3434+}
3535+3636+}