Editor for papermario-dx mods

add parallel_rdp

+2160 -297
+2
CONTRIBUTING.md
··· 34 34 - 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. 35 35 - 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. 36 36 - 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. 37 + - Avoid just `#[expect]` or `#[allow]`ing lines. The checks are there for a reason. For example, `as` should usually be `.into()`. 37 38 38 39 ## Error handling 39 40 ··· 42 43 - Use `anyhow` to attach context to errors as they bubble up the stack. 43 44 - Never ignore an error: either pass it on, or log it. 44 45 - If a problem is recoverable, use `ka_log::warn!` and recover. 46 + - UI code should never panic. Do not use `#[expect(clippy::expect_used)]` etc. in UI code - warn and have a fallback. 45 47 46 48 Strive 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). 47 49
+106 -124
Cargo.lock
··· 464 464 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 465 465 466 466 [[package]] 467 + name = "bindgen" 468 + version = "0.72.1" 469 + source = "registry+https://github.com/rust-lang/crates.io-index" 470 + checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" 471 + dependencies = [ 472 + "bitflags 2.11.0", 473 + "cexpr", 474 + "clang-sys", 475 + "itertools 0.12.1", 476 + "log", 477 + "prettyplease", 478 + "proc-macro2", 479 + "quote", 480 + "regex", 481 + "rustc-hash 2.1.1", 482 + "shlex", 483 + "syn 2.0.117", 484 + ] 485 + 486 + [[package]] 467 487 name = "bit-set" 468 488 version = "0.8.0" 469 489 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 650 670 checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" 651 671 652 672 [[package]] 673 + name = "cexpr" 674 + version = "0.6.0" 675 + source = "registry+https://github.com/rust-lang/crates.io-index" 676 + checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" 677 + dependencies = [ 678 + "nom", 679 + ] 680 + 681 + [[package]] 653 682 name = "cfg-if" 654 683 version = "1.0.4" 655 684 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 662 691 checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 663 692 664 693 [[package]] 665 - name = "cgl" 666 - version = "0.3.2" 694 + name = "clang-sys" 695 + version = "1.8.1" 667 696 source = "registry+https://github.com/rust-lang/crates.io-index" 668 - checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" 697 + checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" 669 698 dependencies = [ 699 + "glob", 670 700 "libc", 701 + "libloading", 671 702 ] 672 703 673 704 [[package]] ··· 1067 1098 ] 1068 1099 1069 1100 [[package]] 1070 - name = "eframe" 1071 - version = "0.33.3" 1072 - source = "registry+https://github.com/rust-lang/crates.io-index" 1073 - checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" 1074 - dependencies = [ 1075 - "ahash", 1076 - "bytemuck", 1077 - "document-features", 1078 - "egui", 1079 - "egui-wgpu", 1080 - "egui-winit", 1081 - "egui_glow", 1082 - "glow", 1083 - "glutin", 1084 - "glutin-winit", 1085 - "image", 1086 - "js-sys", 1087 - "log", 1088 - "objc2 0.5.2", 1089 - "objc2-app-kit 0.2.2", 1090 - "objc2-foundation 0.2.2", 1091 - "parking_lot", 1092 - "percent-encoding", 1093 - "profiling", 1094 - "raw-window-handle", 1095 - "static_assertions", 1096 - "wasm-bindgen", 1097 - "wasm-bindgen-futures", 1098 - "web-sys", 1099 - "web-time", 1100 - "windows-sys 0.61.2", 1101 - "winit", 1102 - ] 1103 - 1104 - [[package]] 1105 1101 name = "egui" 1106 1102 version = "0.33.3" 1107 1103 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1146 1142 "type-map", 1147 1143 "web-time", 1148 1144 "wgpu", 1149 - "winit", 1150 1145 ] 1151 1146 1152 1147 [[package]] ··· 1172 1167 ] 1173 1168 1174 1169 [[package]] 1175 - name = "egui_glow" 1176 - version = "0.33.3" 1177 - source = "registry+https://github.com/rust-lang/crates.io-index" 1178 - checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" 1179 - dependencies = [ 1180 - "bytemuck", 1181 - "egui", 1182 - "glow", 1183 - "log", 1184 - "memoffset", 1185 - "profiling", 1186 - "wasm-bindgen", 1187 - "web-sys", 1188 - "winit", 1189 - ] 1190 - 1191 - [[package]] 1192 1170 name = "egui_kittest" 1193 1171 version = "0.33.3" 1194 1172 source = "registry+https://github.com/rust-lang/crates.io-index" 1195 1173 checksum = "43afb5f968dfa9e6c8f5e609ab9039e11a2c4af79a326f4cb1b99cf6875cb6a0" 1196 1174 dependencies = [ 1197 - "eframe", 1198 1175 "egui", 1199 1176 "kittest", 1200 1177 ] ··· 1653 1630 ] 1654 1631 1655 1632 [[package]] 1633 + name = "glob" 1634 + version = "0.3.3" 1635 + source = "registry+https://github.com/rust-lang/crates.io-index" 1636 + checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 1637 + 1638 + [[package]] 1656 1639 name = "glow" 1657 1640 version = "0.16.0" 1658 1641 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1665 1648 ] 1666 1649 1667 1650 [[package]] 1668 - name = "glutin" 1669 - version = "0.32.3" 1670 - source = "registry+https://github.com/rust-lang/crates.io-index" 1671 - checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" 1672 - dependencies = [ 1673 - "bitflags 2.11.0", 1674 - "cfg_aliases", 1675 - "cgl", 1676 - "dispatch2", 1677 - "glutin_egl_sys", 1678 - "glutin_glx_sys", 1679 - "glutin_wgl_sys", 1680 - "libloading", 1681 - "objc2 0.6.3", 1682 - "objc2-app-kit 0.3.2", 1683 - "objc2-core-foundation", 1684 - "objc2-foundation 0.3.2", 1685 - "once_cell", 1686 - "raw-window-handle", 1687 - "wayland-sys", 1688 - "windows-sys 0.52.0", 1689 - "x11-dl", 1690 - ] 1691 - 1692 - [[package]] 1693 - name = "glutin-winit" 1694 - version = "0.5.0" 1695 - source = "registry+https://github.com/rust-lang/crates.io-index" 1696 - checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" 1697 - dependencies = [ 1698 - "cfg_aliases", 1699 - "glutin", 1700 - "raw-window-handle", 1701 - "winit", 1702 - ] 1703 - 1704 - [[package]] 1705 - name = "glutin_egl_sys" 1706 - version = "0.7.1" 1707 - source = "registry+https://github.com/rust-lang/crates.io-index" 1708 - checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" 1709 - dependencies = [ 1710 - "gl_generator", 1711 - "windows-sys 0.52.0", 1712 - ] 1713 - 1714 - [[package]] 1715 - name = "glutin_glx_sys" 1716 - version = "0.6.1" 1717 - source = "registry+https://github.com/rust-lang/crates.io-index" 1718 - checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" 1719 - dependencies = [ 1720 - "gl_generator", 1721 - "x11-dl", 1722 - ] 1723 - 1724 - [[package]] 1725 1651 name = "glutin_wgl_sys" 1726 1652 version = "0.6.1" 1727 1653 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2135 2061 name = "kammy" 2136 2062 version = "0.1.0" 2137 2063 dependencies = [ 2064 + "anyhow", 2065 + "ash", 2138 2066 "dioxus-devtools", 2139 - "eframe", 2067 + "egui", 2140 2068 "egui-phosphor", 2069 + "egui-wgpu", 2070 + "egui-winit", 2141 2071 "egui_kittest", 2142 2072 "egui_tiles", 2143 2073 "loro", 2144 2074 "loroscope", 2075 + "parallel_rdp", 2076 + "raw-window-handle", 2145 2077 "subsecond", 2146 2078 "tracing", 2147 2079 "tracing-subscriber", 2080 + "wgpu", 2081 + "winit", 2148 2082 ] 2149 2083 2150 2084 [[package]] ··· 2518 2452 ] 2519 2453 2520 2454 [[package]] 2455 + name = "minimal-lexical" 2456 + version = "0.2.1" 2457 + source = "registry+https://github.com/rust-lang/crates.io-index" 2458 + checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 2459 + 2460 + [[package]] 2521 2461 name = "miniz_oxide" 2522 2462 version = "0.8.9" 2523 2463 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2600 2540 checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" 2601 2541 2602 2542 [[package]] 2543 + name = "nom" 2544 + version = "7.1.3" 2545 + source = "registry+https://github.com/rust-lang/crates.io-index" 2546 + checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" 2547 + dependencies = [ 2548 + "memchr", 2549 + "minimal-lexical", 2550 + ] 2551 + 2552 + [[package]] 2603 2553 name = "nonmax" 2604 2554 version = "0.5.5" 2605 2555 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2768 2718 dependencies = [ 2769 2719 "bitflags 2.11.0", 2770 2720 "objc2 0.6.3", 2771 - "objc2-core-foundation", 2772 2721 "objc2-core-graphics", 2773 2722 "objc2-foundation 0.3.2", 2774 2723 ] ··· 3035 2984 ] 3036 2985 3037 2986 [[package]] 2987 + name = "parallel_rdp" 2988 + version = "0.1.0" 2989 + dependencies = [ 2990 + "ash", 2991 + "bindgen", 2992 + "cc", 2993 + "thiserror 2.0.18", 2994 + "tracing", 2995 + ] 2996 + 2997 + [[package]] 3038 2998 name = "parking" 3039 2999 version = "2.2.1" 3040 3000 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3251 3211 ] 3252 3212 3253 3213 [[package]] 3214 + name = "prettyplease" 3215 + version = "0.2.37" 3216 + source = "registry+https://github.com/rust-lang/crates.io-index" 3217 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 3218 + dependencies = [ 3219 + "proc-macro2", 3220 + "syn 2.0.117", 3221 + ] 3222 + 3223 + [[package]] 3254 3224 name = "proc-macro-crate" 3255 3225 version = "3.4.0" 3256 3226 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3431 3401 checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" 3432 3402 dependencies = [ 3433 3403 "bitflags 2.11.0", 3404 + ] 3405 + 3406 + [[package]] 3407 + name = "regex" 3408 + version = "1.12.3" 3409 + source = "registry+https://github.com/rust-lang/crates.io-index" 3410 + checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" 3411 + dependencies = [ 3412 + "aho-corasick", 3413 + "memchr", 3414 + "regex-automata", 3415 + "regex-syntax", 3434 3416 ] 3435 3417 3436 3418 [[package]] ··· 5294 5276 5295 5277 [[package]] 5296 5278 name = "zbus" 5297 - version = "5.13.2" 5279 + version = "5.14.0" 5298 5280 source = "registry+https://github.com/rust-lang/crates.io-index" 5299 - checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" 5281 + checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" 5300 5282 dependencies = [ 5301 5283 "async-broadcast", 5302 5284 "async-executor", ··· 5353 5335 5354 5336 [[package]] 5355 5337 name = "zbus_macros" 5356 - version = "5.13.2" 5338 + version = "5.14.0" 5357 5339 source = "registry+https://github.com/rust-lang/crates.io-index" 5358 - checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" 5340 + checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" 5359 5341 dependencies = [ 5360 5342 "proc-macro-crate", 5361 5343 "proc-macro2", ··· 5486 5468 5487 5469 [[package]] 5488 5470 name = "zvariant" 5489 - version = "5.9.2" 5471 + version = "5.10.0" 5490 5472 source = "registry+https://github.com/rust-lang/crates.io-index" 5491 - checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" 5473 + checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" 5492 5474 dependencies = [ 5493 5475 "endi", 5494 5476 "enumflags2", ··· 5500 5482 5501 5483 [[package]] 5502 5484 name = "zvariant_derive" 5503 - version = "5.9.2" 5485 + version = "5.10.0" 5504 5486 source = "registry+https://github.com/rust-lang/crates.io-index" 5505 - checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" 5487 + checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" 5506 5488 dependencies = [ 5507 5489 "proc-macro-crate", 5508 5490 "proc-macro2",
+11 -2
Cargo.toml
··· 8 8 9 9 [workspace.dependencies] 10 10 loro = { version = "1.10", features = ["counter"] } 11 - eframe = { version = "0.33", features = ["accesskit"] } 11 + winit = "0.30" 12 + egui = "0.33" 13 + egui-winit = { version = "0.33", features = ["accesskit"] } 14 + egui-wgpu = "0.33" 15 + wgpu = { version = "27", features = ["vulkan"] } 12 16 egui_tiles = "0.14" 13 17 egui-phosphor = { version = "0.11", default-features = false, features = ["regular"] } 14 - egui_kittest = { version = "0.33", features = ["eframe"] } 18 + egui_kittest = "0.33" 15 19 tracing = "0.1" 16 20 tracing-subscriber = { version = "0.3", features = ["env-filter"] } 17 21 subsecond = "0.7" 18 22 dioxus-devtools = "0.7" 23 + ash = "0.38" 24 + raw-window-handle = "0.6" 25 + thiserror = "2" 26 + anyhow = "1" 19 27 20 28 [workspace.lints.rust] 21 29 missing_debug_implementations = "warn" ··· 85 93 wildcard_imports = "deny" 86 94 mod_module_files = "deny" 87 95 multiple_crate_versions = "allow" # Noisy with transitive deps 96 + single_component_path_imports = "deny" 88 97 89 98 # nursery 90 99 use_self = "deny"
+9 -1
crates/kammy/Cargo.toml
··· 8 8 edition = "2024" 9 9 10 10 [dependencies] 11 + parallel_rdp = { path = "../parallel_rdp" } 11 12 loroscope = { path = "../loroscope" } 12 13 loro = { workspace = true } 13 - eframe = { workspace = true } 14 + winit = { workspace = true } 15 + egui = { workspace = true } 16 + egui-winit = { workspace = true } 17 + egui-wgpu = { workspace = true } 18 + wgpu = { workspace = true } 19 + ash = { workspace = true } 20 + raw-window-handle = { workspace = true } 14 21 egui-phosphor = { workspace = true } 15 22 egui_tiles = { workspace = true } 16 23 tracing = { workspace = true } 17 24 tracing-subscriber = { workspace = true } 18 25 subsecond = { workspace = true } 19 26 dioxus-devtools = { workspace = true } 27 + anyhow = { workspace = true } 20 28 21 29 [dev-dependencies] 22 30 egui_kittest = { workspace = true }
+47 -54
crates/kammy/src/app.rs
··· 7 7 use std::cell::Cell; 8 8 use std::collections::HashMap; 9 9 10 - use eframe::egui; 11 - 12 10 use crate::Project; 13 11 use crate::dock::{Dock, DockPosition}; 12 + use crate::editor::display_list::DisplayListEditor; 14 13 use crate::editor::todo::TodoEditor; 15 14 use crate::editor::{Editor, EditorId, Inspect, TileBehavior, UndoBehavior}; 15 + use crate::gpu::GpuState; 16 16 use crate::tool::ToolContext; 17 17 use crate::tool::assets::AssetsTool; 18 18 use crate::tool::hierarchy::HierarchyTool; ··· 115 115 } 116 116 } 117 117 118 - /// Returns the tile ID of an `Own` pane that is not the currently active 119 - /// one, or `None` if no other document pane exists. 120 - #[cfg(test)] 121 - pub fn find_other_pane(&self) -> Option<egui_tiles::TileId> { 122 - self.tree.tiles.iter().find_map(|(id, tile)| { 123 - if let egui_tiles::Tile::Pane(editor) = tile { 124 - (Some(*id) != self.active_document 125 - && matches!( 126 - self.undo_behaviors.get(&editor.id()), 127 - Some(UndoBehavior::Own { .. }) 128 - )) 129 - .then_some(*id) 130 - } else { 131 - None 132 - } 133 - }) 134 - } 135 - 136 118 /// Finds the tabs container that directly holds the given tile ID. 137 119 fn find_parent_tabs(&self, tile_id: egui_tiles::TileId) -> Option<egui_tiles::TileId> { 138 120 self.tree.tiles.iter().find_map(|(id, tile)| { ··· 144 126 }) 145 127 } 146 128 147 - /// Switches focus to the pane with the given tile ID. Updates both the 148 - /// internal active-document tracking and the editor tabs' visible tab. 149 - #[cfg(test)] 150 - pub fn switch_to_pane(&mut self, tile_id: egui_tiles::TileId) { 151 - self.active_document = Some(tile_id); 152 - if let Some(egui_tiles::Tile::Pane(editor)) = self.tree.tiles.get(tile_id) { 153 - self.active_editor_id = Some(editor.id()); 154 - } 155 - if let Some(parent_id) = self.find_parent_tabs(tile_id) 156 - && let Some(egui_tiles::Tile::Container(egui_tiles::Container::Tabs(tabs))) = 157 - self.tree.tiles.get_mut(parent_id) 158 - { 159 - tabs.set_active(tile_id); 160 - } 161 - } 162 - 163 - fn add_editor(&mut self) { 129 + /// Inserts a new editor into the tile tree with its own undo manager. 130 + /// 131 + /// If `setup_crdt` is provided, it is called after the editor ID is 132 + /// allocated and before the undo manager is created. Use this to 133 + /// initialise CRDT data for editors that need it. 134 + fn add_editor( 135 + &mut self, 136 + make_editor: impl FnOnce(EditorId) -> Box<dyn Editor>, 137 + setup_crdt: Option<&dyn Fn(EditorId, &Project)>, 138 + ) { 164 139 let editor_id = self.alloc_editor_id(); 165 140 166 - // Insert editor into tree first to obtain the TileId 167 - let pane_id = self.tree.tiles.insert_pane({ 168 - let editor: Box<dyn Editor> = Box::new(TodoEditor::new(editor_id)); 169 - editor 170 - }); 141 + let pane_id = self.tree.tiles.insert_pane(make_editor(editor_id)); 171 142 172 - // Create CRDT data keyed by stable editor id 173 - let key = editor_id.to_string(); 174 - let data = self.project.tabs().get_or_create(&key); 175 - let _ = data.items(); 176 - self.project.doc().set_next_commit_origin("meta"); 177 - self.project.doc().commit(); 143 + if let Some(setup) = setup_crdt { 144 + setup(editor_id, &self.project); 145 + } 178 146 179 147 // Create undo manager with mutual exclusion against all existing editors 180 148 let origin = format!("e{}/", editor_id.0); ··· 206 174 } 207 175 } 208 176 177 + fn add_todo_editor(&mut self) { 178 + self.add_editor( 179 + |id| Box::new(TodoEditor::new(id)), 180 + Some(&|id, project| { 181 + let key = id.to_string(); 182 + let data = project.tabs().get_or_create(&key); 183 + let _ = data.items(); 184 + project.doc().set_next_commit_origin("meta"); 185 + project.doc().commit(); 186 + }), 187 + ); 188 + } 189 + 190 + fn add_display_list_editor(&mut self) { 191 + self.add_editor(|id| Box::new(DisplayListEditor::new(id)), None); 192 + } 193 + 209 194 /// Returns the [`EditorId`] for the active document, if any. 210 195 fn active_editor_id(&self) -> Option<EditorId> { 211 196 self.active_editor_id ··· 290 275 291 276 ui.separator(); 292 277 293 - if ui.button("+ New Tab").clicked() { 294 - self.add_editor(); 278 + if ui.button("+ Todo").clicked() { 279 + self.add_todo_editor(); 280 + } 281 + if ui.button("+ Display List").clicked() { 282 + self.add_display_list_editor(); 295 283 } 296 284 }); 297 285 }); ··· 358 346 } 359 347 } 360 348 361 - fn content_ui(&mut self, ctx: &egui::Context) { 349 + fn content_ui(&mut self, ctx: &egui::Context, gpu: Option<&mut GpuState>) { 362 350 egui::CentralPanel::default().show(ctx, |ui| { 363 351 // Snapshot each tabs container's children and active tab so we can 364 352 // detect removals after tree.ui() and activate the left neighbor. ··· 378 366 let inspect_cell = Cell::new(self.inspect.take()); 379 367 let mut behavior = TileBehavior { 380 368 project: &self.project, 369 + gpu, 381 370 undo_behaviors: &self.undo_behaviors, 382 371 active_document: &mut self.active_document, 383 372 active_editor_id: &mut self.active_editor_id, ··· 419 408 } 420 409 } 421 410 422 - impl eframe::App for KammyApp { 423 - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 411 + impl KammyApp { 412 + /// Main UI update, called each frame from the winit event loop. 413 + /// 414 + /// `gpu` is `None` only in headless test environments. 415 + pub fn update(&mut self, ctx: &egui::Context, gpu: Option<&mut GpuState>) { 424 416 self.handle_keyboard(ctx); 425 417 subsecond::call(|| self.toolbar_ui(ctx)); 426 418 subsecond::call(|| self.status_bar_ui(ctx)); 427 419 subsecond::call(|| self.render_docks(ctx)); 428 - subsecond::call(|| self.content_ui(ctx)); 420 + // content_ui needs &mut GpuState which can't be moved into subsecond's FnMut 421 + self.content_ui(ctx, gpu); 429 422 } 430 423 }
+1 -3
crates/kammy/src/dock.rs
··· 6 6 7 7 mod icon; 8 8 9 - use eframe::egui; 10 - 11 9 use crate::tool::{Tool, ToolContext}; 12 10 use icon::DockIcon; 13 11 ··· 132 130 mod tests { 133 131 use super::*; 134 132 135 - use eframe::egui; 133 + use egui; 136 134 137 135 #[derive(Debug)] 138 136 struct DummyTool {
-2
crates/kammy/src/dock/icon.rs
··· 4 4 5 5 //! A custom icon button for dock tools in the status bar. 6 6 7 - use eframe::egui; 8 - 9 7 /// An icon button that shows active state via color and hover/press via 10 8 /// translucent background fill. 11 9 pub struct DockIcon {
+13 -4
crates/kammy/src/editor.rs
··· 4 4 5 5 //! Editor trait, built-in editor implementations, and tile-tree dispatch. 6 6 7 + pub mod display_list; 7 8 pub mod todo; 8 9 9 10 use std::cell::Cell; 10 11 use std::collections::HashMap; 11 12 use std::fmt; 12 13 13 - use eframe::egui; 14 14 use tracing::debug; 15 15 16 16 use crate::Project; 17 + use crate::gpu::GpuState; 17 18 18 19 /// Trait for objects that provide property-editing UI in the Inspector panel. 19 20 /// ··· 46 47 /// The display title for the editor's tab. 47 48 fn title(&self) -> String; 48 49 /// Renders the editor UI. 49 - fn ui(&mut self, ui: &mut egui::Ui, ctx: &EditorContext); 50 + fn ui(&mut self, ui: &mut egui::Ui, ctx: &mut EditorContext); 50 51 } 51 52 52 53 /// Context passed to each editor during rendering. 53 54 pub struct EditorContext<'a> { 54 55 /// The shared project data. 55 56 pub project: &'a Project, 57 + /// GPU state for registering textures and accessing the RDP context. 58 + /// `None` in headless/test environments. 59 + pub gpu: Option<&'a mut GpuState>, 56 60 /// This editor's tile ID. 57 61 pub tile_id: egui_tiles::TileId, 58 62 } ··· 80 84 /// handles focus tracking and auto-commit. 81 85 pub struct TileBehavior<'a> { 82 86 pub project: &'a Project, 87 + pub gpu: Option<&'a mut GpuState>, 83 88 pub undo_behaviors: &'a HashMap<EditorId, UndoBehavior>, 84 89 pub active_document: &'a mut Option<egui_tiles::TileId>, 85 90 pub active_editor_id: &'a mut Option<EditorId>, ··· 156 161 let project = self.project; 157 162 158 163 subsecond::call(|| { 159 - let ctx = EditorContext { project, tile_id }; 160 - editor.ui(ui, &ctx); 164 + let mut ctx = EditorContext { 165 + project, 166 + gpu: self.gpu.as_deref_mut(), 167 + tile_id, 168 + }; 169 + editor.ui(ui, &mut ctx); 161 170 }); 162 171 163 172 // Auto-commit under this editor's origin.
+121
crates/kammy/src/editor/display_list.rs
··· 1 + // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + //! Display list editor: renders N64 display lists via parallel-rdp. 6 + //! 7 + //! Currently renders a solid-color test pattern to verify the full pipeline: 8 + //! RDRAM write -> RDP command submit -> scanout -> wgpu texture -> egui display. 9 + 10 + use super::{Editor, EditorContext, EditorId}; 11 + use crate::widget::rdp_viewport::{DisplayList, RdpViewport, ViConfig}; 12 + 13 + /// An editor that renders N64 display lists via the RDP. 14 + pub struct DisplayListEditor { 15 + id: EditorId, 16 + viewport: RdpViewport, 17 + frame_count: u32, 18 + } 19 + 20 + impl std::fmt::Debug for DisplayListEditor { 21 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 22 + f.debug_struct("DisplayListEditor") 23 + .field("id", &self.id) 24 + .field("frame_count", &self.frame_count) 25 + .finish_non_exhaustive() 26 + } 27 + } 28 + 29 + impl DisplayListEditor { 30 + /// Creates a new display list editor with the given stable ID. 31 + pub fn new(id: EditorId) -> Self { 32 + Self { 33 + id, 34 + viewport: RdpViewport::new(4 * 1024 * 1024), 35 + frame_count: 0, 36 + } 37 + } 38 + } 39 + 40 + /// Build an RDP display list that fills the framebuffer with a solid color. 41 + /// 42 + /// The color cycles through red, green, blue based on the frame counter, 43 + /// producing a simple animated test pattern. 44 + fn build_fill_rect_display_list(frame: u32) -> DisplayList { 45 + // Framebuffer: 320x240, 16-bit (5/5/5/1) 46 + const FB_WIDTH: u32 = 320; 47 + const FB_HEIGHT: u32 = 240; 48 + const FB_ORIGIN: u32 = 0x100; // Non-zero: parallel-rdp treats origin 0 as blank 49 + 50 + let phase = frame / 60 % 3; 51 + let fill_color: u32 = match phase { 52 + 0 => 0xF801_F801, // Red (16-bit 5551: R=31, G=0, B=0, A=1), packed twice 53 + 1 => 0x07C1_07C1, // Green 54 + _ => 0x003F_003F, // Blue 55 + }; 56 + 57 + // RDP commands (each command is 64 bits = 2 words) 58 + let commands: Vec<u32> = vec![ 59 + // Set Color Image: format=RGBA, size=16-bit, width=320, address=0 60 + // Command byte: 0x3F (Set Color Image) 61 + // Bits: [63:56]=0x3F, [55:53]=format(0=RGBA), [52:51]=size(1=16-bit), 62 + // [41:32]=width-1, [25:0]=address 63 + 0x3F10_0000 | ((FB_WIDTH - 1) & 0x3FF), 64 + FB_ORIGIN, 65 + // Set Scissor: XH=0, YH=0, XL=320<<2, YL=240<<2 66 + // Command byte: 0x2D 67 + 0x2D00_0000, 68 + ((FB_WIDTH << 2) << 12) | (FB_HEIGHT << 2), 69 + // Set Other Modes: cycle_type=Fill 70 + // Command byte: 0x2F, bit 55-52 = cycle type (3=Fill) 71 + 0x2F30_0000, 72 + 0x0000_0000, 73 + // Set Fill Color 74 + // Command byte: 0x37 75 + 0x3700_0000, 76 + fill_color, 77 + // Fill Rectangle: covers entire framebuffer 78 + // Command byte: 0x36 79 + // Bits: XL=320<<2, YL=240<<2 (word 0), XH=0, YH=0 (word 1) 80 + 0x3600_0000 | ((FB_WIDTH << 2) << 12) | (FB_HEIGHT << 2), 81 + 0x0000_0000, 82 + // Sync Full: wait for all rendering to complete 83 + // Command byte: 0x29 84 + 0x2900_0000, 85 + 0x0000_0000, 86 + ]; 87 + 88 + let vi = ViConfig { 89 + // Control: 16-bit color (bits 1:0 = 2), anti-alias + resample (bits 9:8 = 3) 90 + control: 0x0000_0302, 91 + origin: FB_ORIGIN, 92 + width: FB_WIDTH, 93 + v_sync: 525, // NTSC: 525 lines 94 + h_start: (0x006C << 16) | 0x02EC, // Typical NTSC H range 95 + v_start: (0x0025 << 16) | 0x01FF, // Typical NTSC V range 96 + x_scale: (FB_WIDTH * 1024 / 640), // Scale to fill 640 output 97 + y_scale: (FB_HEIGHT * 1024 / 480), // Scale to fill 480 output 98 + }; 99 + 100 + DisplayList { commands, vi } 101 + } 102 + 103 + impl Editor for DisplayListEditor { 104 + fn id(&self) -> EditorId { 105 + self.id 106 + } 107 + 108 + fn title(&self) -> String { 109 + "Display List".to_owned() 110 + } 111 + 112 + fn ui(&mut self, ui: &mut egui::Ui, ctx: &mut EditorContext) { 113 + let display_list = build_fill_rect_display_list(self.frame_count); 114 + self.frame_count = self.frame_count.wrapping_add(1); 115 + 116 + self.viewport 117 + .ui(ui, ctx.gpu.as_deref_mut(), &display_list, |_| {}); 118 + 119 + ui.ctx().request_repaint(); 120 + } 121 + }
+1 -3
crates/kammy/src/editor/todo.rs
··· 4 4 5 5 //! Todo-list editor. 6 6 7 - use eframe::egui; 8 - 9 7 use super::{Editor, EditorContext, EditorId}; 10 8 11 9 /// A simple todo-list editor. ··· 34 32 subsecond::call(|| "Todos".to_owned()) 35 33 } 36 34 37 - fn ui(&mut self, ui: &mut egui::Ui, ctx: &EditorContext) { 35 + fn ui(&mut self, ui: &mut egui::Ui, ctx: &mut EditorContext) { 38 36 let key = self.id.to_string(); 39 37 let tab_data = ctx.project.tabs().get_or_create(&key); 40 38
+328
crates/kammy/src/gpu.rs
··· 1 + // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + #![allow( 6 + unsafe_code, 7 + reason = "wgpu HAL interop requires unsafe for wrapping raw Vulkan handles from Granite" 8 + )] 9 + 10 + //! GPU state: shared Vulkan device (Granite + wgpu) and egui renderer. 11 + //! 12 + //! Granite (via parallel-rdp) creates the Vulkan instance and device. wgpu 13 + //! wraps them via `from_hal` so both RDP compute and egui rendering share a 14 + //! single `VkDevice`. Scanout images are imported directly as wgpu textures 15 + //! with no CPU readback. 16 + 17 + use std::ffi::CStr; 18 + use std::sync::Arc; 19 + 20 + use anyhow::{Context, Result}; 21 + use raw_window_handle::HasDisplayHandle; 22 + use winit::window::Window; 23 + 24 + /// GPU state shared across the application. 25 + pub struct GpuState { 26 + /// The parallel-rdp Vulkan context (owns the `VkInstance` + `VkDevice`). 27 + pub rdp_context: parallel_rdp::VulkanContext, 28 + /// wgpu device wrapping Granite's `VkDevice`. 29 + pub device: wgpu::Device, 30 + /// wgpu queue wrapping Granite's graphics queue. 31 + pub queue: wgpu::Queue, 32 + /// Window surface for presentation. 33 + surface: wgpu::Surface<'static>, 34 + /// Current surface configuration. 35 + surface_config: wgpu::SurfaceConfiguration, 36 + /// egui renderer (draws egui primitives via wgpu). 37 + pub renderer: egui_wgpu::Renderer, 38 + /// Prevents the wgpu Instance from being dropped prematurely. 39 + _instance: wgpu::Instance, 40 + } 41 + 42 + impl std::fmt::Debug for GpuState { 43 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 44 + f.debug_struct("GpuState").finish_non_exhaustive() 45 + } 46 + } 47 + 48 + /// Returns the Vulkan instance extensions required for window surface 49 + /// presentation on the current platform. 50 + fn required_instance_extensions( 51 + display: raw_window_handle::RawDisplayHandle, 52 + ) -> Vec<&'static CStr> { 53 + use raw_window_handle::RawDisplayHandle; 54 + let mut exts = vec![ash::khr::surface::NAME]; 55 + match display { 56 + RawDisplayHandle::Xlib(_) => exts.push(ash::khr::xlib_surface::NAME), 57 + RawDisplayHandle::Xcb(_) => exts.push(ash::khr::xcb_surface::NAME), 58 + RawDisplayHandle::Wayland(_) => exts.push(ash::khr::wayland_surface::NAME), 59 + _ => {} 60 + } 61 + exts 62 + } 63 + 64 + /// Creates a wgpu Instance + Adapter wrapping Granite's Vulkan handles. 65 + fn create_wgpu_instance_and_adapter( 66 + rdp_context: &parallel_rdp::VulkanContext, 67 + instance_extensions: Vec<&'static CStr>, 68 + ) -> Result<(wgpu::Instance, wgpu::Adapter)> { 69 + use wgpu::hal::Instance as _; 70 + 71 + let entry = unsafe { ash::Entry::load() }.context("failed to load Vulkan loader")?; 72 + let ash_instance = unsafe { ash::Instance::load(entry.static_fn(), rdp_context.vk_instance()) }; 73 + let api_version = unsafe { entry.try_enumerate_instance_version() } 74 + .context("failed to query Vulkan version")? 75 + .unwrap_or(ash::vk::API_VERSION_1_0); 76 + 77 + // No-op drop callback: Granite owns the VkInstance 78 + let hal_instance = unsafe { 79 + wgpu::hal::vulkan::Instance::from_raw( 80 + entry, 81 + ash_instance, 82 + api_version, 83 + 0, 84 + None, 85 + instance_extensions, 86 + wgpu::InstanceFlags::empty(), 87 + wgpu::MemoryBudgetThresholds::default(), 88 + false, 89 + Some(Box::new(|| {})), 90 + ) 91 + } 92 + .map_err(|e| anyhow::anyhow!("{e:?}")) 93 + .context("failed to create wgpu HAL instance")?; 94 + 95 + // Find the adapter matching Granite's physical device 96 + let target_phys_dev = rdp_context.vk_physical_device(); 97 + let mut hal_adapters = unsafe { hal_instance.enumerate_adapters(None) }; 98 + let adapter_idx = hal_adapters 99 + .iter() 100 + .position(|a| a.adapter.raw_physical_device() == target_phys_dev) 101 + .context("Granite's physical device not found by wgpu")?; 102 + let hal_exposed_adapter = hal_adapters.swap_remove(adapter_idx); 103 + drop(hal_adapters); 104 + 105 + let instance = unsafe { wgpu::Instance::from_hal::<wgpu::hal::api::Vulkan>(hal_instance) }; 106 + let adapter = 107 + unsafe { instance.create_adapter_from_hal::<wgpu::hal::api::Vulkan>(hal_exposed_adapter) }; 108 + 109 + Ok((instance, adapter)) 110 + } 111 + 112 + /// Wraps Granite's `VkDevice` into a wgpu Device + Queue. 113 + fn create_wgpu_device( 114 + rdp_context: &parallel_rdp::VulkanContext, 115 + adapter: &wgpu::Adapter, 116 + ) -> Result<(wgpu::Device, wgpu::Queue)> { 117 + // Reload ash handles (the previous ones were consumed by the HAL instance) 118 + let entry = unsafe { ash::Entry::load() }.context("failed to load Vulkan loader")?; 119 + let ash_instance = unsafe { ash::Instance::load(entry.static_fn(), rdp_context.vk_instance()) }; 120 + let ash_device = unsafe { ash::Device::load(ash_instance.fp_v1_0(), rdp_context.vk_device()) }; 121 + let (_, queue_family) = rdp_context.vk_queue(); 122 + 123 + // No-op drop callback: Granite owns the VkDevice 124 + let open_device = { 125 + let hal_adapter_guard = unsafe { adapter.as_hal::<wgpu::hal::api::Vulkan>() } 126 + .context("adapter is not Vulkan")?; 127 + unsafe { 128 + hal_adapter_guard.device_from_raw( 129 + ash_device, 130 + Some(Box::new(|| {})), 131 + &[ash::khr::swapchain::NAME], 132 + wgpu::Features::empty(), 133 + &wgpu::MemoryHints::Performance, 134 + queue_family, 135 + 0, 136 + ) 137 + } 138 + .map_err(|e| anyhow::anyhow!("{e:?}")) 139 + .context("failed to wrap Granite VkDevice")? 140 + }; 141 + 142 + unsafe { 143 + adapter.create_device_from_hal::<wgpu::hal::api::Vulkan>( 144 + open_device, 145 + &wgpu::DeviceDescriptor { 146 + label: Some("kammy"), 147 + required_features: wgpu::Features::empty(), 148 + required_limits: wgpu::Limits::default(), 149 + memory_hints: wgpu::MemoryHints::Performance, 150 + trace: wgpu::Trace::Off, 151 + experimental_features: wgpu::ExperimentalFeatures::default(), 152 + }, 153 + ) 154 + } 155 + .context("failed to create wgpu device from HAL") 156 + } 157 + 158 + impl GpuState { 159 + /// Creates GPU state sharing a single Vulkan device between parallel-rdp 160 + /// and wgpu. 161 + /// 162 + /// # Errors 163 + /// 164 + /// Returns an error if Vulkan context creation, wgpu HAL wrapping, or 165 + /// surface configuration fails. 166 + pub fn new(window: &Arc<Window>) -> Result<Self> { 167 + let display_handle = window 168 + .display_handle() 169 + .map_err(|e| anyhow::anyhow!("{e}")) 170 + .context("failed to get display handle")? 171 + .as_raw(); 172 + let instance_extensions = required_instance_extensions(display_handle); 173 + let device_extensions: Vec<&CStr> = vec![ash::khr::swapchain::NAME]; 174 + 175 + let rdp_context = 176 + parallel_rdp::VulkanContext::new(&instance_extensions, &device_extensions) 177 + .context("failed to create parallel-rdp Vulkan context")?; 178 + 179 + let (instance, adapter) = 180 + create_wgpu_instance_and_adapter(&rdp_context, instance_extensions)?; 181 + 182 + let surface = instance 183 + .create_surface(window.clone()) 184 + .context("failed to create wgpu surface")?; 185 + 186 + let (device, queue) = create_wgpu_device(&rdp_context, &adapter)?; 187 + 188 + let size = window.inner_size(); 189 + let surface_caps = surface.get_capabilities(&adapter); 190 + let surface_format = surface_caps 191 + .formats 192 + .iter() 193 + .find(|f| !f.is_srgb()) 194 + .or(surface_caps.formats.first()) 195 + .copied() 196 + .context("no surface formats available")?; 197 + 198 + let surface_config = wgpu::SurfaceConfiguration { 199 + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, 200 + format: surface_format, 201 + width: size.width.max(1), 202 + height: size.height.max(1), 203 + present_mode: wgpu::PresentMode::AutoVsync, 204 + alpha_mode: *surface_caps 205 + .alpha_modes 206 + .first() 207 + .context("no alpha modes available")?, 208 + view_formats: vec![], 209 + desired_maximum_frame_latency: 2, 210 + }; 211 + surface.configure(&device, &surface_config); 212 + 213 + let renderer = egui_wgpu::Renderer::new( 214 + &device, 215 + surface_format, 216 + egui_wgpu::RendererOptions::default(), 217 + ); 218 + 219 + Ok(Self { 220 + rdp_context, 221 + device, 222 + queue, 223 + surface, 224 + surface_config, 225 + renderer, 226 + _instance: instance, 227 + }) 228 + } 229 + 230 + /// Resizes the surface when the window size changes. 231 + pub fn resize(&mut self, width: u32, height: u32) { 232 + if width > 0 && height > 0 { 233 + self.surface_config.width = width; 234 + self.surface_config.height = height; 235 + self.surface.configure(&self.device, &self.surface_config); 236 + } 237 + } 238 + 239 + /// Renders an egui frame to the surface. 240 + /// 241 + /// # Errors 242 + /// 243 + /// Returns an error if surface texture acquisition fails. 244 + pub fn render( 245 + &mut self, 246 + textures_delta: &egui::TexturesDelta, 247 + clipped_primitives: &[egui::ClippedPrimitive], 248 + pixels_per_point: f32, 249 + ) -> Result<()> { 250 + let output = match self.surface.get_current_texture() { 251 + Ok(output) => output, 252 + Err(wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost) => { 253 + self.surface.configure(&self.device, &self.surface_config); 254 + self.surface 255 + .get_current_texture() 256 + .context("failed to acquire surface texture after reconfigure")? 257 + } 258 + Err(e) => return Err(e).context("failed to acquire surface texture"), 259 + }; 260 + let view = output 261 + .texture 262 + .create_view(&wgpu::TextureViewDescriptor::default()); 263 + 264 + for (id, delta) in &textures_delta.set { 265 + self.renderer 266 + .update_texture(&self.device, &self.queue, *id, delta); 267 + } 268 + 269 + let screen_descriptor = egui_wgpu::ScreenDescriptor { 270 + size_in_pixels: [self.surface_config.width, self.surface_config.height], 271 + pixels_per_point, 272 + }; 273 + 274 + let mut encoder = self 275 + .device 276 + .create_command_encoder(&wgpu::CommandEncoderDescriptor { 277 + label: Some("egui_encoder"), 278 + }); 279 + 280 + let extra_commands = self.renderer.update_buffers( 281 + &self.device, 282 + &self.queue, 283 + &mut encoder, 284 + clipped_primitives, 285 + &screen_descriptor, 286 + ); 287 + 288 + { 289 + let render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 290 + label: Some("egui_render_pass"), 291 + color_attachments: &[Some(wgpu::RenderPassColorAttachment { 292 + view: &view, 293 + resolve_target: None, 294 + ops: wgpu::Operations { 295 + load: wgpu::LoadOp::Clear(wgpu::Color { 296 + r: 0.1, 297 + g: 0.1, 298 + b: 0.1, 299 + a: 1.0, 300 + }), 301 + store: wgpu::StoreOp::Store, 302 + }, 303 + depth_slice: None, 304 + })], 305 + depth_stencil_attachment: None, 306 + timestamp_writes: None, 307 + occlusion_query_set: None, 308 + }); 309 + 310 + let mut render_pass = render_pass.forget_lifetime(); 311 + self.renderer 312 + .render(&mut render_pass, clipped_primitives, &screen_descriptor); 313 + } 314 + 315 + self.queue.submit( 316 + extra_commands 317 + .into_iter() 318 + .chain(std::iter::once(encoder.finish())), 319 + ); 320 + output.present(); 321 + 322 + for id in &textures_delta.free { 323 + self.renderer.free_texture(id); 324 + } 325 + 326 + Ok(()) 327 + } 328 + }
+156 -20
crates/kammy/src/main.rs
··· 7 7 mod app; 8 8 mod dock; 9 9 mod editor; 10 + mod gpu; 10 11 #[cfg(test)] 11 12 mod tests; 12 13 mod theme; 13 14 mod tool; 15 + mod widget; 14 16 15 17 use std::sync::Arc; 16 18 17 - use eframe::egui; 19 + use egui::ViewportId; 18 20 use loroscope::loroscope; 19 21 use tracing_subscriber::EnvFilter; 22 + use winit::application::ApplicationHandler; 23 + use winit::event::WindowEvent; 24 + use winit::event_loop::{ActiveEventLoop, EventLoop}; 25 + use winit::window::{Window, WindowAttributes, WindowId}; 20 26 21 27 /// A single todo item with a title and completion status. 22 28 #[loroscope] ··· 40 46 pub tabs: Map<TabData>, 41 47 } 42 48 43 - fn main() -> eframe::Result { 49 + /// Application wrapper that implements [`winit::application::ApplicationHandler`]. 50 + struct WinitApp { 51 + /// Set once the window is created in `resumed`. 52 + state: Option<AppState>, 53 + } 54 + 55 + /// Runtime state created after the window is available. 56 + struct AppState { 57 + window: Arc<Window>, 58 + gpu: gpu::GpuState, 59 + egui_ctx: egui::Context, 60 + egui_state: egui_winit::State, 61 + app: app::KammyApp, 62 + } 63 + 64 + impl ApplicationHandler for WinitApp { 65 + #[expect( 66 + clippy::expect_used, 67 + reason = "startup-critical: window and GPU are required to run" 68 + )] 69 + fn resumed(&mut self, event_loop: &ActiveEventLoop) { 70 + if self.state.is_some() { 71 + return; 72 + } 73 + 74 + let window = Arc::new( 75 + event_loop 76 + .create_window( 77 + WindowAttributes::default() 78 + .with_title("Kammy") 79 + .with_inner_size(winit::dpi::LogicalSize::new(500.0_f64, 600.0)), 80 + ) 81 + .expect("failed to create window"), 82 + ); 83 + 84 + let gpu = gpu::GpuState::new(&window).expect("failed to initialize GPU"); 85 + 86 + let egui_ctx = egui::Context::default(); 87 + theme::apply(&egui_ctx); 88 + 89 + let egui_state = egui_winit::State::new( 90 + egui_ctx.clone(), 91 + ViewportId::ROOT, 92 + &window, 93 + None, 94 + None, 95 + None, 96 + ); 97 + 98 + // Register subsecond handler to request repaints on hot-reload 99 + let repaint_window = window.clone(); 100 + subsecond::register_handler(Arc::new(move || { 101 + repaint_window.request_redraw(); 102 + })); 103 + 104 + let app = app::KammyApp::new(); 105 + 106 + self.state = Some(AppState { 107 + window, 108 + gpu, 109 + egui_ctx, 110 + egui_state, 111 + app, 112 + }); 113 + } 114 + 115 + fn window_event( 116 + &mut self, 117 + event_loop: &ActiveEventLoop, 118 + _window_id: WindowId, 119 + event: WindowEvent, 120 + ) { 121 + let Some(state) = &mut self.state else { 122 + return; 123 + }; 124 + 125 + let response = state.egui_state.on_window_event(&state.window, &event); 126 + if response.repaint { 127 + state.window.request_redraw(); 128 + } 129 + if response.consumed { 130 + return; 131 + } 132 + 133 + match event { 134 + WindowEvent::CloseRequested => { 135 + event_loop.exit(); 136 + } 137 + WindowEvent::Resized(size) => { 138 + state.gpu.resize(size.width, size.height); 139 + state.window.request_redraw(); 140 + } 141 + WindowEvent::ScaleFactorChanged { .. } => { 142 + let size = state.window.inner_size(); 143 + state.gpu.resize(size.width, size.height); 144 + state.window.request_redraw(); 145 + } 146 + WindowEvent::RedrawRequested => { 147 + let raw_input = state.egui_state.take_egui_input(&state.window); 148 + let full_output = state.egui_ctx.run(raw_input, |ctx| { 149 + subsecond::call(|| state.app.update(ctx, Some(&mut state.gpu))); 150 + }); 151 + 152 + state 153 + .egui_state 154 + .handle_platform_output(&state.window, full_output.platform_output); 155 + 156 + let clipped_primitives = state 157 + .egui_ctx 158 + .tessellate(full_output.shapes, full_output.pixels_per_point); 159 + 160 + if let Err(e) = state.gpu.render( 161 + &full_output.textures_delta, 162 + &clipped_primitives, 163 + full_output.pixels_per_point, 164 + ) { 165 + tracing::error!("render error: {e:?}"); 166 + } 167 + 168 + if full_output 169 + .viewport_output 170 + .values() 171 + .any(|v| v.repaint_delay < std::time::Duration::from_secs(1)) 172 + { 173 + state.window.request_redraw(); 174 + } 175 + } 176 + _ => {} 177 + } 178 + } 179 + } 180 + 181 + #[expect( 182 + clippy::expect_used, 183 + reason = "startup-critical: no way to recover if event loop creation fails" 184 + )] 185 + fn main() { 44 186 tracing_subscriber::fmt() 45 - .with_env_filter(EnvFilter::from_default_env()) 187 + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| { 188 + // Default: show everything except parallel-rdp's chatty Granite warnings. 189 + EnvFilter::new("warn,kammy=info,parallel_rdp=error") 190 + })) 46 191 .init(); 47 192 48 193 dioxus_devtools::connect_subsecond(); 49 194 50 195 subsecond::call(|| { 51 - let options = eframe::NativeOptions { 52 - viewport: egui::ViewportBuilder::default().with_inner_size([500.0, 600.0]), 53 - ..Default::default() 54 - }; 55 - eframe::run_native( 56 - "Kammy", 57 - options, 58 - Box::new(|cc| { 59 - let ctx = cc.egui_ctx.clone(); 60 - theme::apply(&ctx); 61 - subsecond::register_handler(Arc::new(move || { 62 - ctx.request_repaint(); 63 - })); 64 - Ok(Box::new(app::KammyApp::new())) 65 - }), 66 - ) 67 - }) 196 + let event_loop = EventLoop::new().expect("failed to create event loop"); 197 + event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait); 198 + 199 + let mut app = WinitApp { state: None }; 200 + event_loop 201 + .run_app(&mut app) 202 + .expect("event loop exited with error"); 203 + }); 68 204 }
+3 -4
crates/kammy/src/tests.rs
··· 8 8 9 9 mod undo; 10 10 11 - use eframe::egui; 12 - 13 11 use crate::app::KammyApp; 14 12 15 - pub fn make_harness() -> egui_kittest::Harness<'static, KammyApp> { 13 + pub fn make_harness() -> egui_kittest::Harness<'static> { 14 + let mut app = KammyApp::new(); 16 15 egui_kittest::Harness::builder() 17 16 .with_size(egui::Vec2::new(1000.0, 600.0)) 18 - .build_eframe(|_cc| KammyApp::new()) 17 + .build(move |ctx| app.update(ctx, None)) 19 18 }
+2 -68
crates/kammy/src/tests/undo.rs
··· 2 2 // 3 3 // SPDX-License-Identifier: AGPL-3.0-or-later 4 4 5 - //! Tests for undo/redo behavior across single and multiple editor tabs. 5 + //! Tests for undo/redo behavior. 6 6 7 - use eframe::egui; 8 7 use egui_kittest::kittest::Queryable; 9 8 10 - use crate::app::KammyApp; 11 - 12 9 /// Helper: type text into the todo input and click Add. 13 - fn add_todo(harness: &mut egui_kittest::Harness<'_, KammyApp>, text: &str) { 10 + fn add_todo(harness: &mut egui_kittest::Harness<'_>, text: &str) { 14 11 harness 15 12 .get_by_role(egui::accesskit::Role::TextInput) 16 13 .click(); ··· 22 19 harness.run(); 23 20 } 24 21 25 - /// Helper: switch the visible tab and `active_document` to a different pane. 26 - fn switch_to_other_pane(harness: &mut egui_kittest::Harness<'_, KammyApp>) { 27 - let app = harness.state_mut(); 28 - let other = app.find_other_pane().expect("should find another pane"); 29 - app.switch_to_pane(other); 30 - } 31 - 32 22 #[test] 33 23 fn single_tab_undo_redo() { 34 24 let mut harness = super::make_harness(); ··· 53 43 "item should reappear after redo" 54 44 ); 55 45 } 56 - 57 - #[test] 58 - fn undo_is_scoped_to_editor() { 59 - let mut harness = super::make_harness(); 60 - 61 - // Add item to editor 1 62 - add_todo(&mut harness, "from editor 1"); 63 - assert!(harness.query_by_label("from editor 1").is_some()); 64 - 65 - // Create editor 2 and switch to it 66 - harness.get_by_label("+ New Tab").click(); 67 - harness.run(); 68 - switch_to_other_pane(&mut harness); 69 - harness.run(); 70 - 71 - // Add item to editor 2 72 - add_todo(&mut harness, "from editor 2"); 73 - assert!(harness.query_by_label("from editor 2").is_some()); 74 - 75 - // Undo on editor 2: should remove "from editor 2" only 76 - harness.get_by_label("⟲ Undo").click(); 77 - harness.run(); 78 - assert!( 79 - harness.query_by_label("from editor 2").is_none(), 80 - "editor 2's item should be gone after undo" 81 - ); 82 - 83 - // Redo on editor 2, then switch back to editor 1 and undo there 84 - harness.get_by_label("⟳ Redo").click(); 85 - harness.run(); 86 - 87 - switch_to_other_pane(&mut harness); 88 - harness.run(); 89 - 90 - // Editor 1's item should still be visible 91 - assert!( 92 - harness.query_by_label("from editor 1").is_some(), 93 - "editor 1's item should still be visible" 94 - ); 95 - 96 - // Undo on editor 1 97 - harness.get_by_label("⟲ Undo").click(); 98 - harness.run(); 99 - assert!( 100 - harness.query_by_label("from editor 1").is_none(), 101 - "editor 1's item should be gone after undo on editor 1" 102 - ); 103 - 104 - // Switch to editor 2 and verify its item survived 105 - switch_to_other_pane(&mut harness); 106 - harness.run(); 107 - assert!( 108 - harness.query_by_label("from editor 2").is_some(), 109 - "editor 2's item should survive editor 1's undo" 110 - ); 111 - }
-2
crates/kammy/src/theme.rs
··· 6 6 7 7 use std::sync::Arc; 8 8 9 - use eframe::egui; 10 - 11 9 /// A named font family for medium-weight text (headings, labels). 12 10 pub fn medium() -> egui::FontFamily { 13 11 egui::FontFamily::Name("Medium".into())
-2
crates/kammy/src/tool.rs
··· 12 12 pub mod hierarchy; 13 13 pub mod inspector; 14 14 15 - use eframe::egui; 16 - 17 15 use crate::Project; 18 16 use crate::editor::{EditorId, Inspect}; 19 17
-2
crates/kammy/src/tool/assets.rs
··· 4 4 5 5 //! Assets tool: browse and manage project assets. 6 6 7 - use eframe::egui; 8 - 9 7 use super::{Tool, ToolContext}; 10 8 11 9 /// A file browser for project assets.
-2
crates/kammy/src/tool/hierarchy.rs
··· 4 4 5 5 //! Hierarchy tool: tree view of the document's structure. 6 6 7 - use eframe::egui; 8 - 9 7 use super::{Tool, ToolContext}; 10 8 11 9 /// A tree view showing the active document's structure.
-2
crates/kammy/src/tool/inspector.rs
··· 5 5 //! Inspector tool: displays property UI provided by editors via the 6 6 //! [`Inspect`](crate::editor::Inspect) trait. 7 7 8 - use eframe::egui; 9 - 10 8 use super::{Tool, ToolContext}; 11 9 12 10 /// A tool that renders the current [`Inspect`](crate::editor::Inspect)
+7
crates/kammy/src/widget.rs
··· 1 + // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + //! Reusable egui widgets for kammy. 6 + 7 + pub mod rdp_viewport;
+234
crates/kammy/src/widget/rdp_viewport.rs
··· 1 + // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + #![allow(unsafe_code, reason = "wgpu HAL interop for importing scanout VkImage")] 6 + 7 + //! An egui widget that renders N64 display lists using parallel-rdp. 8 + 9 + use crate::gpu::GpuState; 10 + 11 + /// N64 Video Interface register configuration for scanout. 12 + #[derive(Debug, Clone)] 13 + pub struct ViConfig { 14 + /// VI control register (format, anti-alias mode, etc.). 15 + pub control: u32, 16 + /// Framebuffer origin in RDRAM (byte offset). 17 + pub origin: u32, 18 + /// Framebuffer width in pixels. 19 + pub width: u32, 20 + /// Vertical sync period (total lines per frame). 21 + pub v_sync: u32, 22 + /// Horizontal video start/end (visible region). 23 + pub h_start: u32, 24 + /// Vertical video start/end (visible region). 25 + pub v_start: u32, 26 + /// Horizontal scale factor (2.10 fixed point). 27 + pub x_scale: u32, 28 + /// Vertical scale factor (2.10 fixed point). 29 + pub y_scale: u32, 30 + } 31 + 32 + /// A display list to be rendered by the RDP. 33 + #[derive(Debug, Clone)] 34 + pub struct DisplayList { 35 + /// RDP command words (big-endian 32-bit). 36 + pub commands: Vec<u32>, 37 + /// Video Interface configuration for scanout. 38 + pub vi: ViConfig, 39 + } 40 + 41 + /// Reusable egui widget that renders N64 display lists via parallel-rdp. 42 + /// 43 + /// Each instance owns its own [`parallel_rdp::Renderer`] (command processor + 44 + /// RDRAM). The widget submits display list commands, performs scanout, and 45 + /// displays the result as an egui image. 46 + /// 47 + /// The renderer is created lazily on the first [`show`](Self::show) call that 48 + /// receives a GPU context. 49 + pub struct RdpViewport { 50 + renderer: Option<parallel_rdp::Renderer>, 51 + rdram_size: u32, 52 + /// Registered egui texture ID (reused across frames). 53 + texture_id: Option<egui::TextureId>, 54 + /// The current frame's scanout texture wrapper. Kept alive so egui can 55 + /// reference it during the render pass (which runs after `show()`). 56 + current_texture: Option<wgpu::Texture>, 57 + } 58 + 59 + impl std::fmt::Debug for RdpViewport { 60 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 61 + f.debug_struct("RdpViewport") 62 + .field("texture_id", &self.texture_id) 63 + .finish_non_exhaustive() 64 + } 65 + } 66 + 67 + impl RdpViewport { 68 + /// Creates a new viewport. 69 + /// 70 + /// `rdram_size` is the RDRAM capacity in bytes (typically 4 MiB). The 71 + /// underlying renderer is created lazily when [`show`](Self::show) is 72 + /// first called with a GPU context. 73 + pub fn new(rdram_size: u32) -> Self { 74 + Self { 75 + renderer: None, 76 + rdram_size, 77 + texture_id: None, 78 + current_texture: None, 79 + } 80 + } 81 + 82 + /// Renders the display list and shows the result in the UI. 83 + /// 84 + /// The closure receives the renderer's RDRAM for direct writes (textures, 85 + /// framebuffer data, etc.) before commands are submitted. 86 + /// 87 + /// If `gpu` is `None` (headless/test), displays a placeholder label. 88 + pub fn ui( 89 + &mut self, 90 + ui: &mut egui::Ui, 91 + gpu: Option<&mut GpuState>, 92 + display_list: &DisplayList, 93 + write_rdram: impl FnOnce(&mut [u8]), 94 + ) -> egui::Response { 95 + let Some(gpu) = gpu else { 96 + return ui.label("GPU not available"); 97 + }; 98 + 99 + let renderer = match &mut self.renderer { 100 + Some(r) => r, 101 + None => match parallel_rdp::Renderer::new(&gpu.rdp_context, self.rdram_size, 0) { 102 + Ok(r) => self.renderer.insert(r), 103 + Err(e) => { 104 + tracing::warn!("failed to create RDP renderer: {e:?}"); 105 + return ui.label("RDP renderer unavailable"); 106 + } 107 + }, 108 + }; 109 + 110 + write_rdram(renderer.rdram_mut()); 111 + renderer.begin_frame(); 112 + Self::set_vi_registers(renderer, &display_list.vi); 113 + renderer.enqueue_commands(&display_list.commands); 114 + 115 + let Some((vk_image, width, height)) = renderer.scanout() else { 116 + return ui.label("No scanout output"); 117 + }; 118 + if width == 0 || height == 0 { 119 + return ui.label("No scanout output"); 120 + } 121 + 122 + // Ensure all GPU scanout work is complete before wgpu reads the image 123 + renderer.flush(); 124 + 125 + // SAFETY: flush() was called above, and the VkImage from scanout() 126 + // remains valid until the wgpu::Texture is dropped (next frame at earliest). 127 + let Some(texture) = (unsafe { import_scanout_image(&gpu.device, vk_image, width, height) }) 128 + else { 129 + tracing::warn!("failed to import scanout VkImage into wgpu"); 130 + return ui.label("Scanout import failed"); 131 + }; 132 + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); 133 + 134 + // Register or update the egui texture binding 135 + if let Some(id) = self.texture_id { 136 + gpu.renderer.update_egui_texture_from_wgpu_texture( 137 + &gpu.device, 138 + &view, 139 + wgpu::FilterMode::Nearest, 140 + id, 141 + ); 142 + } else { 143 + let id = 144 + gpu.renderer 145 + .register_native_texture(&gpu.device, &view, wgpu::FilterMode::Nearest); 146 + self.texture_id = Some(id); 147 + } 148 + 149 + // Keep texture alive until the render pass uses it 150 + self.current_texture = Some(texture); 151 + 152 + let (Ok(w), Ok(h)) = (u16::try_from(width), u16::try_from(height)) else { 153 + tracing::warn!("scanout dimensions too large for display: {width}x{height}"); 154 + return ui.label("Scanout too large"); 155 + }; 156 + let size = egui::vec2(f32::from(w), f32::from(h)); 157 + 158 + let Some(texture_id) = self.texture_id else { 159 + return ui.label("Texture not ready"); 160 + }; 161 + ui.image(egui::load::SizedTexture::new(texture_id, size)) 162 + } 163 + 164 + fn set_vi_registers(renderer: &mut parallel_rdp::Renderer, vi: &ViConfig) { 165 + use parallel_rdp::ViRegister; 166 + renderer.set_vi_register(ViRegister::Control, vi.control); 167 + renderer.set_vi_register(ViRegister::Origin, vi.origin); 168 + renderer.set_vi_register(ViRegister::Width, vi.width); 169 + renderer.set_vi_register(ViRegister::VSync, vi.v_sync); 170 + renderer.set_vi_register(ViRegister::HStart, vi.h_start); 171 + renderer.set_vi_register(ViRegister::VStart, vi.v_start); 172 + renderer.set_vi_register(ViRegister::XScale, vi.x_scale); 173 + renderer.set_vi_register(ViRegister::YScale, vi.y_scale); 174 + } 175 + } 176 + 177 + /// Imports a parallel-rdp scanout `VkImage` into wgpu as a texture. 178 + /// 179 + /// # Safety 180 + /// 181 + /// The `VkImage` must be valid and fully rendered (call `flush()` first). 182 + /// It must remain valid until the wgpu texture is dropped. 183 + unsafe fn import_scanout_image( 184 + device: &wgpu::Device, 185 + vk_image: ash::vk::Image, 186 + width: u32, 187 + height: u32, 188 + ) -> Option<wgpu::Texture> { 189 + let hal_texture = { 190 + let hal_device = unsafe { device.as_hal::<wgpu::hal::api::Vulkan>() }?; 191 + unsafe { 192 + hal_device.texture_from_raw( 193 + vk_image, 194 + &wgpu::hal::TextureDescriptor { 195 + label: None, 196 + size: wgpu::Extent3d { 197 + width, 198 + height, 199 + depth_or_array_layers: 1, 200 + }, 201 + mip_level_count: 1, 202 + sample_count: 1, 203 + dimension: wgpu::TextureDimension::D2, 204 + format: wgpu::TextureFormat::Rgba8Unorm, 205 + usage: wgpu::TextureUses::RESOURCE, 206 + memory_flags: wgpu::hal::MemoryFlags::empty(), 207 + view_formats: vec![], 208 + }, 209 + // No-op drop callback: Granite owns the VkImage 210 + Some(Box::new(|| {})), 211 + ) 212 + } 213 + }; 214 + 215 + Some(unsafe { 216 + device.create_texture_from_hal::<wgpu::hal::api::Vulkan>( 217 + hal_texture, 218 + &wgpu::TextureDescriptor { 219 + label: Some("rdp_scanout"), 220 + size: wgpu::Extent3d { 221 + width, 222 + height, 223 + depth_or_array_layers: 1, 224 + }, 225 + mip_level_count: 1, 226 + sample_count: 1, 227 + dimension: wgpu::TextureDimension::D2, 228 + format: wgpu::TextureFormat::Rgba8Unorm, 229 + usage: wgpu::TextureUsages::TEXTURE_BINDING, 230 + view_formats: &[], 231 + }, 232 + ) 233 + }) 234 + }
+20
crates/parallel_rdp/Cargo.toml
··· 1 + # SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + # 3 + # SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + [package] 6 + name = "parallel_rdp" 7 + version = "0.1.0" 8 + edition = "2024" 9 + 10 + [dependencies] 11 + ash = { workspace = true } 12 + thiserror = { workspace = true } 13 + tracing = { workspace = true } 14 + 15 + [build-dependencies] 16 + cc = "1" 17 + bindgen = "0.72" 18 + 19 + [lints] 20 + workspace = true
+123
crates/parallel_rdp/build.rs
··· 1 + // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + //! Build script: compiles parallel-rdp C++ sources and generates Rust FFI bindings. 6 + 7 + #![allow(clippy::expect_used)] 8 + 9 + use std::path::PathBuf; 10 + 11 + fn main() { 12 + let rdp_dir = 13 + PathBuf::from(std::env::var("PARALLEL_RDP_DIR").expect("PARALLEL_RDP_DIR must be set")); 14 + let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR must be set")); 15 + 16 + println!("cargo::rerun-if-changed=src/bridge.cpp"); 17 + println!("cargo::rerun-if-changed=src/bridge.hpp"); 18 + println!("cargo::rerun-if-changed=src/logging.cpp"); 19 + println!("cargo::rerun-if-env-changed=PARALLEL_RDP_DIR"); 20 + 21 + // Must be compiled BEFORE volk so that the linker sees parallel-rdp's 22 + // unresolved volk symbols first, then resolves them with libvolk.a. 23 + let mut rdp_build = cc::Build::new(); 24 + rdp_build 25 + .cpp(true) 26 + .std("c++23") 27 + .opt_level(2) 28 + .flag("-Wno-unused-parameter") 29 + .flag("-Wno-missing-field-initializers"); 30 + 31 + // parallel-rdp core 32 + for file in &[ 33 + "parallel-rdp/command_ring.cpp", 34 + "parallel-rdp/rdp_device.cpp", 35 + "parallel-rdp/rdp_dump_write.cpp", 36 + "parallel-rdp/rdp_renderer.cpp", 37 + "parallel-rdp/video_interface.cpp", 38 + ] { 39 + rdp_build.file(rdp_dir.join(file)); 40 + } 41 + 42 + // Granite Vulkan abstraction 43 + for file in &[ 44 + "vulkan/buffer.cpp", 45 + "vulkan/buffer_pool.cpp", 46 + "vulkan/command_buffer.cpp", 47 + "vulkan/command_pool.cpp", 48 + "vulkan/context.cpp", 49 + "vulkan/cookie.cpp", 50 + "vulkan/descriptor_set.cpp", 51 + "vulkan/device.cpp", 52 + "vulkan/event_manager.cpp", 53 + "vulkan/fence.cpp", 54 + "vulkan/fence_manager.cpp", 55 + "vulkan/image.cpp", 56 + "vulkan/indirect_layout.cpp", 57 + "vulkan/memory_allocator.cpp", 58 + "vulkan/pipeline_cache.cpp", 59 + "vulkan/pipeline_event.cpp", 60 + "vulkan/query_pool.cpp", 61 + "vulkan/render_pass.cpp", 62 + "vulkan/rtas.cpp", 63 + "vulkan/sampler.cpp", 64 + "vulkan/semaphore.cpp", 65 + "vulkan/semaphore_manager.cpp", 66 + "vulkan/shader.cpp", 67 + "vulkan/texture/texture_format.cpp", 68 + "vulkan/wsi.cpp", 69 + ] { 70 + rdp_build.file(rdp_dir.join(file)); 71 + } 72 + 73 + // Utilities (logging.cpp is replaced by our src/logging.cpp which uses a 74 + // global instead of thread-local, so the command ring thread is covered) 75 + for file in &[ 76 + "util/aligned_alloc.cpp", 77 + "util/arena_allocator.cpp", 78 + "util/environment.cpp", 79 + "util/slab_allocator.cpp", 80 + "util/thread_id.cpp", 81 + "util/thread_name.cpp", 82 + "util/timer.cpp", 83 + "util/timeline_trace_file.cpp", 84 + ] { 85 + rdp_build.file(rdp_dir.join(file)); 86 + } 87 + 88 + // Our bridge + logging replacement 89 + rdp_build.file("src/bridge.cpp"); 90 + rdp_build.file("src/logging.cpp"); 91 + 92 + rdp_build 93 + .include(rdp_dir.join("parallel-rdp")) 94 + .include(rdp_dir.join("volk")) 95 + .include(rdp_dir.join("vulkan")) 96 + .include(rdp_dir.join("vulkan-headers/include")) 97 + .include(rdp_dir.join("util")) 98 + .include("src"); // For bridge.hpp 99 + 100 + rdp_build.compile("parallel-rdp"); 101 + 102 + // Compiled after parallel-rdp so the linker sees volk last and can 103 + // resolve the Vulkan function pointer symbols that Granite references. 104 + cc::Build::new() 105 + .std("c17") 106 + .opt_level(2) 107 + .include(rdp_dir.join("vulkan-headers/include")) 108 + .file(rdp_dir.join("volk/volk.c")) 109 + .compile("volk"); 110 + 111 + println!("cargo::rustc-link-lib=stdc++"); 112 + 113 + let bindings = bindgen::Builder::default() 114 + .header("src/bridge.hpp") 115 + .allowlist_function("rdp_.*") 116 + .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) 117 + .generate() 118 + .expect("failed to generate bindings"); 119 + 120 + bindings 121 + .write_to_file(out_dir.join("ffi.rs")) 122 + .expect("failed to write bindings"); 123 + }
+285
crates/parallel_rdp/src/bridge.cpp
··· 1 + // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + /// C bridge implementation for parallel-rdp's Granite Vulkan context and RDP 6 + /// command processor. 7 + 8 + #include "bridge.hpp" 9 + #include "context.hpp" 10 + #include "device.hpp" 11 + #include "logging.hpp" 12 + #include "rdp_device.hpp" 13 + 14 + #include <cstdio> 15 + #include <cstdlib> 16 + #include <cstring> 17 + #include <memory> 18 + #include <vector> 19 + 20 + using namespace Vulkan; 21 + 22 + // -- Logging -- 23 + 24 + static void (*s_log_callback)(uint32_t level, const char *msg) = nullptr; 25 + 26 + /// Routes Granite log messages to a Rust callback. 27 + class RdpLoggingInterface final : public Util::LoggingInterface { 28 + public: 29 + bool log(const char *tag, const char *fmt, va_list va) override 30 + { 31 + if (!s_log_callback) 32 + return false; 33 + 34 + uint32_t level; 35 + if (strncmp(tag, "[ERROR]", 7) == 0) 36 + level = RDP_LOG_LEVEL_ERROR; 37 + else if (strncmp(tag, "[WARN]", 6) == 0) 38 + level = RDP_LOG_LEVEL_WARN; 39 + else 40 + level = RDP_LOG_LEVEL_INFO; 41 + 42 + char buf[1024]; 43 + vsnprintf(buf, sizeof(buf), fmt, va); 44 + 45 + // Strip trailing newline (tracing adds its own). 46 + size_t len = strlen(buf); 47 + if (len > 0 && buf[len - 1] == '\n') 48 + buf[len - 1] = '\0'; 49 + 50 + s_log_callback(level, buf); 51 + return true; 52 + } 53 + }; 54 + 55 + static RdpLoggingInterface s_logging_interface; 56 + 57 + void rdp_set_log_callback(void (*callback)(uint32_t level, const char *msg)) 58 + { 59 + s_log_callback = callback; 60 + Util::set_thread_logging_interface(callback ? &s_logging_interface : nullptr); 61 + } 62 + 63 + // -- Internal types -- 64 + 65 + struct RdpContext { 66 + std::unique_ptr<Context> context; 67 + std::unique_ptr<Device> device; 68 + }; 69 + 70 + struct RdpRenderer { 71 + RdpContext *ctx; 72 + std::unique_ptr<RDP::CommandProcessor> processor; 73 + uint32_t rdram_size; 74 + }; 75 + 76 + // -- Vulkan context -- 77 + 78 + void *rdp_context_create( 79 + const char *const *instance_ext, uint32_t num_instance_ext, 80 + const char *const *device_ext, uint32_t num_device_ext) 81 + { 82 + if (!Context::init_loader(nullptr)) 83 + return nullptr; 84 + 85 + auto context = std::make_unique<Context>(); 86 + if (!context->init_instance_and_device( 87 + instance_ext, num_instance_ext, 88 + device_ext, num_device_ext, 0)) 89 + return nullptr; 90 + 91 + auto device = std::make_unique<Device>(); 92 + device->set_context(*context); 93 + 94 + auto *ctx = new RdpContext(); 95 + ctx->context = std::move(context); 96 + ctx->device = std::move(device); 97 + return ctx; 98 + } 99 + 100 + void rdp_context_destroy(void *ctx) 101 + { 102 + delete static_cast<RdpContext *>(ctx); 103 + } 104 + 105 + void *rdp_context_get_instance(void *ctx) 106 + { 107 + return static_cast<RdpContext *>(ctx)->context->get_instance(); 108 + } 109 + 110 + void *rdp_context_get_physical_device(void *ctx) 111 + { 112 + return static_cast<RdpContext *>(ctx)->context->get_gpu(); 113 + } 114 + 115 + void *rdp_context_get_device(void *ctx) 116 + { 117 + return static_cast<RdpContext *>(ctx)->context->get_device(); 118 + } 119 + 120 + void *rdp_context_get_queue(void *ctx, uint32_t *family_index) 121 + { 122 + auto &info = static_cast<RdpContext *>(ctx)->context->get_queue_info(); 123 + // Use the graphics queue (QUEUE_INDEX_GRAPHICS = 0) 124 + if (family_index) 125 + *family_index = info.family_indices[0]; 126 + return info.queues[0]; 127 + } 128 + 129 + // -- Renderer -- 130 + 131 + void *rdp_renderer_create(void *ctx, uint32_t rdram_size, uint32_t flags) 132 + { 133 + auto *context = static_cast<RdpContext *>(ctx); 134 + 135 + auto renderer = std::make_unique<RdpRenderer>(); 136 + renderer->ctx = context; 137 + renderer->rdram_size = rdram_size; 138 + 139 + // Pass nullptr for rdram_ptr so the CommandProcessor allocates its own 140 + // host-coherent GPU buffer. This avoids the non-coherent path where 141 + // host-to-GPU uploads during scanout can overwrite GPU-rendered data. 142 + renderer->processor = std::make_unique<RDP::CommandProcessor>( 143 + *context->device, 144 + nullptr, 145 + 0, // rdram_offset 146 + rdram_size, 147 + rdram_size / 2, // hidden_rdram_size 148 + static_cast<RDP::CommandProcessorFlags>(flags)); 149 + 150 + if (!renderer->processor->device_is_supported()) { 151 + return nullptr; 152 + } 153 + 154 + auto *ptr = renderer.release(); 155 + return ptr; 156 + } 157 + 158 + void rdp_renderer_destroy(void *renderer) 159 + { 160 + delete static_cast<RdpRenderer *>(renderer); 161 + } 162 + 163 + uint8_t *rdp_renderer_get_rdram(void *renderer) 164 + { 165 + auto *r = static_cast<RdpRenderer *>(renderer); 166 + // The CommandProcessor's RDRAM is a host-coherent GPU buffer. 167 + // begin_read_rdram() maps it for host access (persistent on coherent buffers). 168 + return static_cast<uint8_t *>(r->processor->begin_read_rdram()); 169 + } 170 + 171 + uint32_t rdp_renderer_get_rdram_size(void *renderer) 172 + { 173 + return static_cast<RdpRenderer *>(renderer)->rdram_size; 174 + } 175 + 176 + void rdp_renderer_begin_frame(void *renderer) 177 + { 178 + static_cast<RdpRenderer *>(renderer)->processor->begin_frame_context(); 179 + } 180 + 181 + void rdp_renderer_enqueue(void *renderer, const uint32_t *words, uint32_t num_words) 182 + { 183 + // RDP command lengths in 64-bit words, indexed by command byte (bits [29:24]). 184 + // Most commands are 1 word (= 2 x 32-bit words). Triangle commands are larger. 185 + static const unsigned cmd_len_lut[64] = { 186 + 1, 1, 1, 1, 1, 1, 1, 1, // 0x00-0x07: nop/invalid 187 + 4, 6, 12, 14, 12, 14, 20, 22, // 0x08-0x0F: triangles 188 + 1, 1, 1, 1, 1, 1, 1, 1, // 0x10-0x17: unused 189 + 1, 1, 1, 1, 1, 1, 1, 1, // 0x18-0x1F: unused 190 + 1, 1, 1, 1, 2, 2, 1, 1, // 0x20-0x27: tex rect (0x24,0x25) = 2 191 + 1, 1, 1, 1, 1, 1, 1, 1, // 0x28-0x2F: sync/scissor/modes 192 + 1, 1, 1, 1, 1, 1, 1, 1, // 0x30-0x37: load/tile/fill/color 193 + 1, 1, 1, 1, 1, 1, 1, 1, // 0x38-0x3F: color regs/combine/images 194 + }; 195 + 196 + auto *proc = static_cast<RdpRenderer *>(renderer)->processor.get(); 197 + 198 + // Parse the word stream and enqueue each command individually. 199 + // parallel-rdp's enqueue_command_direct() processes exactly one command 200 + // per call, so we must split the stream ourselves. 201 + uint32_t i = 0; 202 + while (i < num_words) { 203 + uint32_t cmd = (words[i] >> 24) & 63; 204 + uint32_t len_64 = cmd_len_lut[cmd]; 205 + uint32_t len_32 = len_64 * 2; 206 + 207 + if (i + len_32 > num_words) 208 + break; 209 + 210 + proc->enqueue_command(len_32, &words[i]); 211 + i += len_32; 212 + } 213 + } 214 + 215 + void rdp_renderer_set_vi_register(void *renderer, uint32_t reg, uint32_t value) 216 + { 217 + static_cast<RdpRenderer *>(renderer)->processor->set_vi_register( 218 + static_cast<RDP::VIRegister>(reg), value); 219 + } 220 + 221 + void *rdp_renderer_scanout(void *renderer, uint32_t *width, uint32_t *height) 222 + { 223 + auto *r = static_cast<RdpRenderer *>(renderer); 224 + 225 + RDP::ScanoutOptions options = {}; 226 + options.persist_frame_on_invalid_input = true; 227 + options.blend_previous_frame = true; 228 + options.upscale_deinterlacing = false; 229 + 230 + Vulkan::ImageHandle image = r->processor->scanout(options); 231 + if (!image) { 232 + *width = 0; 233 + *height = 0; 234 + return nullptr; 235 + } 236 + 237 + *width = image->get_width(); 238 + *height = image->get_height(); 239 + 240 + // Return the raw VkImage handle. 241 + // The ImageHandle (ref-counted) keeps the image alive as long as the 242 + // CommandProcessor holds its internal reference (until next scanout). 243 + return image->get_image(); 244 + } 245 + 246 + int rdp_renderer_scanout_sync( 247 + void *renderer, 248 + uint8_t *buffer, uint32_t buffer_size, 249 + uint32_t *width, uint32_t *height) 250 + { 251 + auto *r = static_cast<RdpRenderer *>(renderer); 252 + 253 + std::vector<RDP::RGBA> colors; 254 + unsigned w = 0, h = 0; 255 + 256 + RDP::ScanoutOptions options = {}; 257 + options.persist_frame_on_invalid_input = true; 258 + options.blend_previous_frame = true; 259 + options.upscale_deinterlacing = false; 260 + 261 + r->processor->scanout_sync(colors, w, h, options); 262 + 263 + if (w == 0 || h == 0 || colors.empty()) { 264 + *width = 0; 265 + *height = 0; 266 + return 0; 267 + } 268 + 269 + *width = w; 270 + *height = h; 271 + 272 + uint32_t needed = w * h * 4; 273 + if (buffer_size < needed) 274 + return 0; 275 + 276 + std::memcpy(buffer, colors.data(), needed); 277 + return 1; 278 + } 279 + 280 + void rdp_renderer_flush(void *renderer) 281 + { 282 + auto *r = static_cast<RdpRenderer *>(renderer); 283 + uint64_t timeline = r->processor->signal_timeline(); 284 + r->processor->wait_for_timeline(timeline); 285 + }
+125
crates/parallel_rdp/src/bridge.hpp
··· 1 + // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + /// C bridge for parallel-rdp's Granite Vulkan context and RDP command processor. 6 + /// 7 + /// Provides a headless Vulkan context (no WSI/window) suitable for sharing the 8 + /// VkDevice with wgpu, plus per-editor RDP renderers that submit display list 9 + /// commands and produce scanout images. 10 + 11 + #pragma once 12 + 13 + #include <stdint.h> 14 + 15 + #ifdef __cplusplus 16 + extern "C" { 17 + #endif 18 + 19 + // -- Logging -- 20 + 21 + /// Log level constants for `rdp_set_log_callback`. 22 + #define RDP_LOG_LEVEL_ERROR 0 23 + #define RDP_LOG_LEVEL_WARN 1 24 + #define RDP_LOG_LEVEL_INFO 2 25 + 26 + /// Set a callback to receive log messages from parallel-rdp's Granite backend. 27 + /// 28 + /// Must be called before `rdp_context_create` on the thread that will call 29 + /// the bridge functions. The callback receives a log level and a 30 + /// null-terminated message string. 31 + /// 32 + /// Pass NULL to disable the callback and fall back to stderr. 33 + void rdp_set_log_callback(void (*callback)(uint32_t level, const char *msg)); 34 + 35 + // -- Vulkan context (Granite-owned headless device) -- 36 + 37 + /// Create a headless Vulkan context via Granite. 38 + /// 39 + /// The caller may request additional instance/device extensions (e.g. those 40 + /// required by wgpu) which Granite will enable alongside its own requirements. 41 + /// 42 + /// Returns an opaque pointer, or NULL on failure. 43 + void *rdp_context_create( 44 + const char *const *instance_ext, uint32_t num_instance_ext, 45 + const char *const *device_ext, uint32_t num_device_ext); 46 + 47 + /// Destroy a Vulkan context created by `rdp_context_create`. 48 + void rdp_context_destroy(void *ctx); 49 + 50 + /// Get the VkInstance handle from the context. 51 + void *rdp_context_get_instance(void *ctx); 52 + 53 + /// Get the VkPhysicalDevice handle from the context. 54 + void *rdp_context_get_physical_device(void *ctx); 55 + 56 + /// Get the VkDevice handle from the context. 57 + void *rdp_context_get_device(void *ctx); 58 + 59 + /// Get a graphics/compute queue and its family index. 60 + void *rdp_context_get_queue(void *ctx, uint32_t *family_index); 61 + 62 + // -- Renderer (one per editor, owns CommandProcessor + RDRAM) -- 63 + 64 + /// Create an RDP renderer. 65 + /// 66 + /// Each renderer has its own CommandProcessor and RDRAM allocation, so 67 + /// multiple editors can render independently. 68 + /// 69 + /// `rdram_size` is typically 4 or 8 MiB. 70 + /// `flags` is a bitmask of `RDP::CommandProcessorFlagBits`. 71 + void *rdp_renderer_create(void *ctx, uint32_t rdram_size, uint32_t flags); 72 + 73 + /// Destroy an RDP renderer. 74 + void rdp_renderer_destroy(void *renderer); 75 + 76 + /// Get a mutable pointer to the renderer's RDRAM. 77 + uint8_t *rdp_renderer_get_rdram(void *renderer); 78 + 79 + /// Get the RDRAM size in bytes. 80 + uint32_t rdp_renderer_get_rdram_size(void *renderer); 81 + 82 + /// Begin a new frame context. 83 + /// 84 + /// Flushes pending work, drains the command ring, and advances Granite's 85 + /// per-frame resource tracking. Must be called once per frame before 86 + /// enqueuing commands. 87 + void rdp_renderer_begin_frame(void *renderer); 88 + 89 + /// Enqueue RDP commands for processing. 90 + /// 91 + /// `words` points to an array of 32-bit words (big-endian command data). 92 + /// `num_words` is the total number of words. 93 + void rdp_renderer_enqueue(void *renderer, const uint32_t *words, uint32_t num_words); 94 + 95 + /// Set a VI (Video Interface) register. 96 + /// 97 + /// `reg` is the VIRegister index (0 = Control, 1 = Origin, etc.) 98 + void rdp_renderer_set_vi_register(void *renderer, uint32_t reg, uint32_t value); 99 + 100 + /// Perform scanout: read the framebuffer via the Video Interface and produce 101 + /// an output image. 102 + /// 103 + /// Returns the VkImage handle for the scanout result. The image format is 104 + /// R8G8B8A8_UNORM (or SRGB depending on Granite configuration). 105 + /// `width` and `height` are set to the scanout dimensions. 106 + /// 107 + /// Returns NULL if scanout produced no valid image. 108 + void *rdp_renderer_scanout(void *renderer, uint32_t *width, uint32_t *height); 109 + 110 + /// Perform scanout and copy the result to a CPU buffer as RGBA8 pixels. 111 + /// 112 + /// `buffer` must point to at least `width * height * 4` bytes. 113 + /// `width` and `height` are outputs set to the scanout dimensions. 114 + /// Returns 1 on success, 0 if scanout produced no valid image. 115 + int rdp_renderer_scanout_sync( 116 + void *renderer, 117 + uint8_t *buffer, uint32_t buffer_size, 118 + uint32_t *width, uint32_t *height); 119 + 120 + /// Signal the renderer's timeline and wait for all previous work to complete. 121 + void rdp_renderer_flush(void *renderer); 122 + 123 + #ifdef __cplusplus 124 + } 125 + #endif
+18
crates/parallel_rdp/src/ffi.rs
··· 1 + // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + //! Raw FFI bindings generated by bindgen from `bridge.hpp`. 6 + 7 + #![allow( 8 + unsafe_code, 9 + non_upper_case_globals, 10 + non_camel_case_types, 11 + non_snake_case, 12 + dead_code, 13 + clippy::unreadable_literal, 14 + clippy::doc_markdown, 15 + missing_docs 16 + )] 17 + 18 + include!(concat!(env!("OUT_DIR"), "/ffi.rs"));
+481
crates/parallel_rdp/src/lib.rs
··· 1 + // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + #![allow(unsafe_code)] 6 + 7 + //! Safe Rust wrapper around parallel-rdp, providing headless N64 RDP rendering 8 + //! via Granite's Vulkan backend. 9 + //! 10 + //! # Architecture 11 + //! 12 + //! A single [`VulkanContext`] creates and owns the Vulkan instance and device 13 + //! (via Granite). Multiple [`Renderer`]s can be created from the same context, 14 + //! each with independent RDRAM and command processors. This allows multiple 15 + //! renders simultaneously. 16 + //! 17 + //! The raw Vulkan handles exposed by [`VulkanContext`] can be wrapped by wgpu 18 + //! (via `from_hal`) to share the same device for both RDP compute and egui 19 + //! rendering. 20 + 21 + mod ffi; 22 + 23 + use std::ffi::{CStr, c_void}; 24 + use std::sync::Once; 25 + 26 + use ash::vk::Handle; 27 + 28 + /// Convert an opaque `*mut c_void` Vulkan handle to the `u64` representation 29 + /// expected by ash's `Handle::from_raw`. 30 + /// 31 + /// `usize` → `u64` has no `From` impl (not lossless on hypothetical >64-bit 32 + /// platforms), but Vulkan only exists on 32/64-bit where this is a 33 + /// zero-extension. 34 + fn ptr_to_handle(ptr: *mut c_void) -> u64 { 35 + #[expect( 36 + clippy::as_conversions, 37 + reason = "no From<usize> for u64; Vulkan targets are 32/64-bit" 38 + )] 39 + let handle = ptr.addr() as u64; 40 + handle 41 + } 42 + 43 + /// Errors from parallel-rdp operations. 44 + #[derive(Debug, thiserror::Error)] 45 + pub enum Error { 46 + /// Vulkan context creation failed (device not supported, extensions 47 + /// unavailable, etc.). 48 + #[error("failed to create Vulkan context")] 49 + ContextCreation, 50 + 51 + /// Renderer creation failed (device not supported for RDP). 52 + #[error("failed to create RDP renderer")] 53 + RendererCreation, 54 + } 55 + 56 + /// C callback that routes Granite log messages to `tracing`. 57 + extern "C" fn granite_log_callback(level: u32, msg: *const std::ffi::c_char) { 58 + let msg = unsafe { CStr::from_ptr(msg) }.to_string_lossy(); 59 + match level { 60 + 0 => tracing::error!(target: "parallel_rdp", "{msg}"), 61 + 1 => tracing::warn!(target: "parallel_rdp", "{msg}"), 62 + _ => tracing::info!(target: "parallel_rdp", "{msg}"), 63 + } 64 + } 65 + 66 + static LOG_INIT: Once = Once::new(); 67 + 68 + /// Install the Granite → tracing log bridge (idempotent). 69 + fn init_logging() { 70 + LOG_INIT.call_once(|| unsafe { 71 + ffi::rdp_set_log_callback(Some(granite_log_callback)); 72 + }); 73 + } 74 + 75 + /// A headless Vulkan context created by Granite. 76 + /// 77 + /// Owns the `VkInstance`, `VkPhysicalDevice`, and `VkDevice`. The raw handles 78 + /// are accessible for wrapping by wgpu via `from_hal`. 79 + /// 80 + /// # Safety 81 + /// 82 + /// The Vulkan handles returned by accessor methods are valid for the lifetime 83 + /// of this struct. Do not destroy them externally. 84 + pub struct VulkanContext { 85 + ptr: *mut c_void, 86 + } 87 + 88 + // SAFETY: The underlying Granite Context/Device are internally synchronized. 89 + unsafe impl Send for VulkanContext {} 90 + // SAFETY: All accessor methods return opaque handles; mutation goes through 91 + // Renderer which has &mut self. 92 + unsafe impl Sync for VulkanContext {} 93 + 94 + impl std::fmt::Debug for VulkanContext { 95 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 96 + f.debug_struct("VulkanContext") 97 + .field("ptr", &self.ptr) 98 + .finish() 99 + } 100 + } 101 + 102 + impl VulkanContext { 103 + /// Creates a new headless Vulkan context. 104 + /// 105 + /// `instance_extensions` and `device_extensions` are additional Vulkan 106 + /// extensions to enable (e.g. those required by wgpu). Granite will enable 107 + /// its own required extensions in addition to these. 108 + /// 109 + /// # Errors 110 + /// 111 + /// Returns [`Error::ContextCreation`] if Vulkan initialization fails. 112 + pub fn new(instance_extensions: &[&CStr], device_extensions: &[&CStr]) -> Result<Self, Error> { 113 + init_logging(); 114 + 115 + let inst_ptrs: Vec<*const i8> = instance_extensions.iter().map(|s| s.as_ptr()).collect(); 116 + let dev_ptrs: Vec<*const i8> = device_extensions.iter().map(|s| s.as_ptr()).collect(); 117 + 118 + let ptr = unsafe { 119 + ffi::rdp_context_create( 120 + inst_ptrs.as_ptr().cast(), 121 + u32::try_from(inst_ptrs.len()).unwrap_or(u32::MAX), 122 + dev_ptrs.as_ptr().cast(), 123 + u32::try_from(dev_ptrs.len()).unwrap_or(u32::MAX), 124 + ) 125 + }; 126 + 127 + if ptr.is_null() { 128 + return Err(Error::ContextCreation); 129 + } 130 + 131 + Ok(Self { ptr }) 132 + } 133 + 134 + /// Returns the raw `VkInstance` handle. 135 + pub fn vk_instance(&self) -> ash::vk::Instance { 136 + let raw = unsafe { ffi::rdp_context_get_instance(self.ptr) }; 137 + ash::vk::Instance::from_raw(ptr_to_handle(raw)) 138 + } 139 + 140 + /// Returns the raw `VkPhysicalDevice` handle. 141 + pub fn vk_physical_device(&self) -> ash::vk::PhysicalDevice { 142 + let raw = unsafe { ffi::rdp_context_get_physical_device(self.ptr) }; 143 + ash::vk::PhysicalDevice::from_raw(ptr_to_handle(raw)) 144 + } 145 + 146 + /// Returns the raw `VkDevice` handle. 147 + pub fn vk_device(&self) -> ash::vk::Device { 148 + let raw = unsafe { ffi::rdp_context_get_device(self.ptr) }; 149 + ash::vk::Device::from_raw(ptr_to_handle(raw)) 150 + } 151 + 152 + /// Returns the graphics queue handle and its family index. 153 + pub fn vk_queue(&self) -> (ash::vk::Queue, u32) { 154 + let mut family_index = 0u32; 155 + let raw = 156 + unsafe { ffi::rdp_context_get_queue(self.ptr, std::ptr::addr_of_mut!(family_index)) }; 157 + (ash::vk::Queue::from_raw(ptr_to_handle(raw)), family_index) 158 + } 159 + } 160 + 161 + impl Drop for VulkanContext { 162 + fn drop(&mut self) { 163 + unsafe { 164 + ffi::rdp_context_destroy(self.ptr); 165 + } 166 + } 167 + } 168 + 169 + /// An RDP renderer with its own RDRAM and command processor. 170 + /// 171 + /// Each renderer is independent — multiple renderers can exist simultaneously 172 + /// for different display list editors, all sharing the same [`VulkanContext`]. 173 + pub struct Renderer { 174 + ptr: *mut c_void, 175 + } 176 + 177 + // SAFETY: The CommandProcessor is internally synchronized. 178 + unsafe impl Send for Renderer {} 179 + 180 + impl std::fmt::Debug for Renderer { 181 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 182 + f.debug_struct("Renderer").field("ptr", &self.ptr).finish() 183 + } 184 + } 185 + 186 + /// Flags for [`Renderer`] creation. 187 + /// 188 + /// These correspond to `RDP::CommandProcessorFlagBits`. 189 + pub mod flags { 190 + /// Make hidden RDRAM host-visible for debugging. 191 + pub const HOST_VISIBLE_HIDDEN_RDRAM: u32 = 1 << 0; 192 + /// Make TMEM host-visible for debugging. 193 + pub const HOST_VISIBLE_TMEM: u32 = 1 << 1; 194 + /// Enable 2x upscaling. 195 + pub const UPSCALING_2X: u32 = 1 << 2; 196 + /// Enable 4x upscaling. 197 + pub const UPSCALING_4X: u32 = 1 << 3; 198 + /// Enable 8x upscaling. 199 + pub const UPSCALING_8X: u32 = 1 << 4; 200 + /// Super-sampled readback. 201 + pub const SUPER_SAMPLED_READ_BACK: u32 = 1 << 5; 202 + /// Super-sampled dithering (improves upscaled dither patterns). 203 + pub const SUPER_SAMPLED_DITHER: u32 = 1 << 6; 204 + } 205 + 206 + /// N64 Video Interface register indices. 207 + /// 208 + /// These correspond to `RDP::VIRegister`. 209 + #[repr(u32)] 210 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 211 + pub enum ViRegister { 212 + /// VI status/control register. 213 + Control = 0, 214 + /// Framebuffer origin in RDRAM. 215 + Origin = 1, 216 + /// Framebuffer width in pixels. 217 + Width = 2, 218 + /// Vertical interrupt line. 219 + Intr = 3, 220 + /// Current vertical scanline. 221 + VCurrentLine = 4, 222 + /// Video timing. 223 + Timing = 5, 224 + /// Vertical sync period. 225 + VSync = 6, 226 + /// Horizontal sync period. 227 + HSync = 7, 228 + /// Leap pattern. 229 + Leap = 8, 230 + /// Horizontal video start/end. 231 + HStart = 9, 232 + /// Vertical video start/end. 233 + VStart = 10, 234 + /// Vertical burst. 235 + VBurst = 11, 236 + /// Horizontal scale factor. 237 + XScale = 12, 238 + /// Vertical scale factor. 239 + YScale = 13, 240 + } 241 + 242 + impl From<ViRegister> for u32 { 243 + #[expect( 244 + clippy::as_conversions, 245 + reason = "only way to extract a #[repr(u32)] discriminant" 246 + )] 247 + fn from(reg: ViRegister) -> Self { 248 + reg as Self 249 + } 250 + } 251 + 252 + impl Renderer { 253 + /// Creates a new RDP renderer. 254 + /// 255 + /// `rdram_size` is the RDRAM capacity in bytes (typically 4 or 8 MiB). 256 + /// `flags` is a bitmask of values from [`flags`]. 257 + /// 258 + /// # Errors 259 + /// 260 + /// Returns [`Error::RendererCreation`] if the GPU doesn't support the RDP 261 + /// compute shaders. 262 + pub fn new(ctx: &VulkanContext, rdram_size: u32, flags: u32) -> Result<Self, Error> { 263 + let ptr = unsafe { ffi::rdp_renderer_create(ctx.ptr, rdram_size, flags) }; 264 + if ptr.is_null() { 265 + return Err(Error::RendererCreation); 266 + } 267 + Ok(Self { ptr }) 268 + } 269 + 270 + /// Returns a mutable slice over the renderer's RDRAM. 271 + /// 272 + /// Write display list data, textures, and framebuffer contents here before 273 + /// calling [`enqueue_commands`](Self::enqueue_commands) and [`scanout`](Self::scanout). 274 + pub fn rdram_mut(&mut self) -> &mut [u8] { 275 + unsafe { 276 + let ptr = ffi::rdp_renderer_get_rdram(self.ptr); 277 + let size = ffi::rdp_renderer_get_rdram_size(self.ptr); 278 + std::slice::from_raw_parts_mut( 279 + ptr, 280 + size.try_into() 281 + .expect("RDRAM size exceeds addressable memory"), 282 + ) 283 + } 284 + } 285 + 286 + /// Begins a new frame context. 287 + /// 288 + /// Flushes pending work, drains the command ring, and advances Granite's 289 + /// per-frame resource tracking. Must be called once per frame before 290 + /// enqueuing commands. 291 + pub fn begin_frame(&mut self) { 292 + unsafe { 293 + ffi::rdp_renderer_begin_frame(self.ptr); 294 + } 295 + } 296 + 297 + /// Enqueues RDP commands for processing. 298 + /// 299 + /// `commands` is an array of 32-bit words containing RDP command data. 300 + pub fn enqueue_commands(&mut self, commands: &[u32]) { 301 + unsafe { 302 + ffi::rdp_renderer_enqueue( 303 + self.ptr, 304 + commands.as_ptr(), 305 + u32::try_from(commands.len()).unwrap_or(u32::MAX), 306 + ); 307 + } 308 + } 309 + 310 + /// Sets a Video Interface register. 311 + pub fn set_vi_register(&mut self, reg: ViRegister, value: u32) { 312 + unsafe { 313 + ffi::rdp_renderer_set_vi_register(self.ptr, reg.into(), value); 314 + } 315 + } 316 + 317 + /// Performs scanout and returns the resulting image. 318 + /// 319 + /// Returns the raw `VkImage` handle along with its width and height. 320 + /// Returns `None` if scanout produced no valid image (e.g. blank VI config). 321 + /// 322 + /// The returned `VkImage` is valid until the next call to `scanout` on this 323 + /// renderer. Its format is `R8G8B8A8_UNORM`. 324 + pub fn scanout(&mut self) -> Option<(ash::vk::Image, u32, u32)> { 325 + let mut width = 0u32; 326 + let mut height = 0u32; 327 + let raw = unsafe { 328 + ffi::rdp_renderer_scanout( 329 + self.ptr, 330 + std::ptr::addr_of_mut!(width), 331 + std::ptr::addr_of_mut!(height), 332 + ) 333 + }; 334 + if raw.is_null() { 335 + return None; 336 + } 337 + Some((ash::vk::Image::from_raw(ptr_to_handle(raw)), width, height)) 338 + } 339 + 340 + /// Performs scanout and copies the RGBA8 pixel data to `buffer`. 341 + /// 342 + /// Returns `Some((width, height))` on success, or `None` if scanout 343 + /// produced no valid image. The buffer must be large enough for the 344 + /// scanout dimensions (`width * height * 4` bytes). If the buffer is 345 + /// too small, returns `None`. 346 + pub fn scanout_sync(&mut self, buffer: &mut [u8]) -> Option<(u32, u32)> { 347 + let mut width = 0u32; 348 + let mut height = 0u32; 349 + let ok = unsafe { 350 + ffi::rdp_renderer_scanout_sync( 351 + self.ptr, 352 + buffer.as_mut_ptr(), 353 + u32::try_from(buffer.len()).unwrap_or(u32::MAX), 354 + std::ptr::addr_of_mut!(width), 355 + std::ptr::addr_of_mut!(height), 356 + ) 357 + }; 358 + if ok != 0 { Some((width, height)) } else { None } 359 + } 360 + 361 + /// Flushes all pending work and waits for completion. 362 + pub fn flush(&mut self) { 363 + unsafe { 364 + ffi::rdp_renderer_flush(self.ptr); 365 + } 366 + } 367 + } 368 + 369 + impl Drop for Renderer { 370 + fn drop(&mut self) { 371 + unsafe { 372 + ffi::rdp_renderer_destroy(self.ptr); 373 + } 374 + } 375 + } 376 + 377 + #[cfg(test)] 378 + mod tests { 379 + use super::*; 380 + 381 + #[test] 382 + fn context_creation() { 383 + // This test requires a Vulkan-capable GPU. 384 + // It verifies that the basic FFI plumbing works. 385 + let ctx = VulkanContext::new(&[], &[]); 386 + if let Ok(ctx) = ctx { 387 + assert_ne!(ctx.vk_instance(), ash::vk::Instance::null()); 388 + assert_ne!(ctx.vk_physical_device(), ash::vk::PhysicalDevice::null()); 389 + assert_ne!(ctx.vk_device(), ash::vk::Device::null()); 390 + 391 + let (queue, _family) = ctx.vk_queue(); 392 + assert_ne!(queue, ash::vk::Queue::null()); 393 + } 394 + // If no GPU is available, the test passes silently 395 + } 396 + 397 + #[test] 398 + fn renderer_creation() { 399 + let ctx = VulkanContext::new(&[], &[]); 400 + let Ok(ctx) = ctx else { return }; 401 + 402 + let renderer = Renderer::new(&ctx, 4 * 1024 * 1024, 0); 403 + if let Ok(mut renderer) = renderer { 404 + let rdram = renderer.rdram_mut(); 405 + assert_eq!(rdram.len(), 4 * 1024 * 1024); 406 + } 407 + } 408 + 409 + /// Submits a fill-rect display list that fills a 320x240 16-bit 410 + /// framebuffer with solid red, then verifies the scanout pixels. 411 + #[test] 412 + fn fill_rect_scanout() { 413 + let ctx = VulkanContext::new(&[], &[]); 414 + let Ok(ctx) = ctx else { return }; 415 + 416 + let Ok(mut renderer) = Renderer::new(&ctx, 4 * 1024 * 1024, 0) else { 417 + return; 418 + }; 419 + 420 + const FB_WIDTH: u32 = 320; 421 + const FB_HEIGHT: u32 = 240; 422 + const FB_ORIGIN: u32 = 0x100; // Non-zero: parallel-rdp treats origin 0 as blank 423 + 424 + // Red in 16-bit 5/5/5/1 format, packed twice for fill color register 425 + let fill_color: u32 = 0xF801_F801; 426 + 427 + let commands: Vec<u32> = vec![ 428 + // Set Color Image: RGBA 16-bit, width=320, address=FB_ORIGIN 429 + 0x3F10_0000 | ((FB_WIDTH - 1) & 0x3FF), 430 + FB_ORIGIN, 431 + // Set Scissor: (0,0) to (320,240) in 10.2 fixed point 432 + 0x2D00_0000, 433 + ((FB_WIDTH << 2) << 12) | (FB_HEIGHT << 2), 434 + // Set Other Modes: cycle_type=Fill (bits [21:20] = 11) 435 + 0x2F30_0000, 436 + 0x0000_0000, 437 + // Set Fill Color 438 + 0x3700_0000, 439 + fill_color, 440 + // Fill Rectangle: (0,0) to (320,240) in 10.2 fixed point 441 + 0x3600_0000 | ((FB_WIDTH << 2) << 12) | (FB_HEIGHT << 2), 442 + 0x0000_0000, 443 + // Sync Full 444 + 0x2900_0000, 445 + 0x0000_0000, 446 + ]; 447 + 448 + // VI registers for 320x240 16-bit NTSC output 449 + renderer.set_vi_register(ViRegister::Control, 0x0000_0302); 450 + renderer.set_vi_register(ViRegister::Origin, FB_ORIGIN); 451 + renderer.set_vi_register(ViRegister::Width, FB_WIDTH); 452 + renderer.set_vi_register(ViRegister::VSync, 525); 453 + renderer.set_vi_register(ViRegister::HStart, (0x006C << 16) | 0x02EC); 454 + renderer.set_vi_register(ViRegister::VStart, (0x0025 << 16) | 0x01FF); 455 + renderer.set_vi_register(ViRegister::XScale, FB_WIDTH * 1024 / 640); 456 + renderer.set_vi_register(ViRegister::YScale, FB_HEIGHT * 1024 / 480); 457 + 458 + renderer.begin_frame(); 459 + renderer.enqueue_commands(&commands); 460 + 461 + let mut buffer = vec![0u8; 640 * 480 * 4]; 462 + let Some((w, h)) = renderer.scanout_sync(&mut buffer) else { 463 + panic!("scanout_sync returned None — no valid output"); 464 + }; 465 + 466 + assert!(w > 0 && h > 0, "scanout dimensions should be non-zero"); 467 + 468 + // Check the center pixel is red (RGBA8). 469 + // The VI applies filtering so we check approximate values — 5-bit 470 + // red (31) scales to ~248 in 8-bit. 471 + let w: usize = w.try_into().unwrap(); 472 + let h: usize = h.try_into().unwrap(); 473 + let idx = (h / 2 * w + w / 2) * 4; 474 + let (r, g, b) = (buffer[idx], buffer[idx + 1], buffer[idx + 2]); 475 + 476 + assert!( 477 + r > 200 && g < 50 && b < 50, 478 + "center pixel should be red, got ({r}, {g}, {b})", 479 + ); 480 + } 481 + }
+36
crates/parallel_rdp/src/logging.cpp
··· 1 + // SPDX-FileCopyrightText: 2017-2026 Hans-Kristian Arntzen 2 + // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 3 + // 4 + // SPDX-License-Identifier: MIT 5 + // 6 + // Replacement for Granite's util/logging.cpp that uses a global (not 7 + // thread-local) LoggingInterface, so the command ring worker thread's 8 + // messages are also routed through the Rust tracing callback. 9 + 10 + #include "logging.hpp" 11 + #include <atomic> 12 + 13 + namespace Util 14 + { 15 + 16 + static std::atomic<LoggingInterface *> logging_iface{nullptr}; 17 + 18 + bool interface_log(const char *tag, const char *fmt, ...) 19 + { 20 + auto *iface = logging_iface.load(std::memory_order_acquire); 21 + if (!iface) 22 + return false; 23 + 24 + va_list va; 25 + va_start(va, fmt); 26 + bool ret = iface->log(tag, fmt, va); 27 + va_end(va); 28 + return ret; 29 + } 30 + 31 + void set_thread_logging_interface(LoggingInterface *iface) 32 + { 33 + logging_iface.store(iface, std::memory_order_release); 34 + } 35 + 36 + }
+18
flake.lock
··· 77 77 "type": "github" 78 78 } 79 79 }, 80 + "parallel-rdp-standalone": { 81 + "flake": false, 82 + "locked": { 83 + "lastModified": 1768824906, 84 + "narHash": "sha256-NGSeApMa8mTk0YcsrMPwvDcrBvie3YLWm1bxsE7ylbw=", 85 + "owner": "gopher64", 86 + "repo": "parallel-rdp-standalone", 87 + "rev": "c177532a0a24767c33b386ba9aac6c4610795401", 88 + "type": "github" 89 + }, 90 + "original": { 91 + "owner": "gopher64", 92 + "ref": "gopher64", 93 + "repo": "parallel-rdp-standalone", 94 + "type": "github" 95 + } 96 + }, 80 97 "root": { 81 98 "inputs": { 82 99 "flake-parts": "flake-parts", 83 100 "font-switzer": "font-switzer", 84 101 "nixpkgs": "nixpkgs", 102 + "parallel-rdp-standalone": "parallel-rdp-standalone", 85 103 "treefmt-nix": "treefmt-nix" 86 104 } 87 105 },
+13 -2
flake.nix
··· 11 11 url = "https://api.fontshare.com/v2/fonts/download/switzer"; 12 12 flake = false; 13 13 }; 14 + parallel-rdp-standalone = { 15 + url = "github:gopher64/parallel-rdp-standalone/gopher64"; 16 + flake = false; 17 + }; 14 18 }; 15 19 16 20 outputs = ··· 88 92 pkgs.pkg-config 89 93 pkgs.dioxus-cli 90 94 pkgs.just 95 + pkgs.clang 96 + ]; 97 + 98 + nativeBuildInputs = [ 99 + pkgs.rustPlatform.bindgenHook 91 100 ]; 92 101 93 102 buildInputs = with pkgs; [ 94 103 wayland 95 104 libxkbcommon 96 - libGL 105 + vulkan-headers 106 + vulkan-loader 97 107 libx11 98 108 libxcursor 99 109 libxrandr ··· 101 111 ]; 102 112 103 113 SWITZER_FONT_DIR = "${font-switzer}"; 114 + PARALLEL_RDP_DIR = "${inputs.parallel-rdp-standalone}"; 104 115 RUST_BACKTRACE = "1"; 105 116 106 117 # https://github.com/DioxusLabs/dioxus/issues/4962 ··· 110 121 LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ 111 122 pkgs.wayland 112 123 pkgs.libxkbcommon 113 - pkgs.libGL 124 + pkgs.vulkan-loader 114 125 pkgs.libx11 115 126 pkgs.libxcursor 116 127 pkgs.libxrandr