dev/system-run-cart #34

Merged
bquarkz merged 11 commits from dev/system-run-cart into master 2026-07-03 20:43:03 +00:00
9 changed files with 308 additions and 6 deletions
Showing only changes of commit badc9b8874 - Show all commits

View File

@ -297,7 +297,7 @@ pub enum Capability {
All,
}
#[derive(Deserialize)]
#[derive(Debug, Clone, Deserialize)]
pub struct CartridgeManifest {
pub magic: String,
pub cartridge_version: u32,

View File

@ -13,6 +13,9 @@ pub use services::async_work::{
AsyncWorkLaneController, AsyncWorkLaneError, AsyncWorkLaneTelemetry, AsyncWorkPriority,
};
pub use services::fs;
pub use services::game_library::{
GameLibrary, GameLibraryDiagnostic, GameLibraryEntry, discover_games_root,
};
pub use services::memcard::MemcardAsyncLaneOperation;
pub use services::process;
pub use services::task;

View File

@ -1,4 +1,4 @@
use crate::{CrashReport, SystemOS};
use crate::{CrashReport, GameLibrary, SystemOS};
use prometeu_hal::color::Color;
use prometeu_hal::primitives::Rect;
use prometeu_hal::{
@ -29,6 +29,7 @@ const COLOR_BUTTON_ACTIVE: Color = Color::rgb(72, 91, 122);
/// PrometeuHub: Launcher and system UI environment.
pub struct PrometeuHub {
home_input_armed: bool,
game_library: GameLibrary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -91,13 +92,21 @@ impl Default for PrometeuHub {
impl PrometeuHub {
pub fn new() -> Self {
Self { home_input_armed: false }
Self { home_input_armed: false, game_library: GameLibrary::empty() }
}
pub fn init(&mut self) {
self.home_input_armed = false;
}
pub fn set_game_library(&mut self, game_library: GameLibrary) {
self.game_library = game_library;
}
pub fn game_library(&self) -> &GameLibrary {
&self.game_library
}
pub fn gui_update(
&mut self,
os: &mut SystemOS,

View File

@ -0,0 +1,270 @@
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::cartridge::CartridgeManifest;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone)]
pub struct GameLibraryEntry {
pub manifest: CartridgeManifest,
pub title: String,
pub app_id: u32,
pub app_version: String,
pub path: PathBuf,
pub discovered_at: SystemTime,
}
impl GameLibraryEntry {
fn new(path: PathBuf, manifest: CartridgeManifest, discovered_at: SystemTime) -> Self {
Self {
title: manifest.title.clone(),
app_id: manifest.app_id,
app_version: manifest.app_version.clone(),
manifest,
path,
discovered_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GameLibraryDiagnostic {
pub path: PathBuf,
pub reason: GameLibraryDiagnosticReason,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GameLibraryDiagnosticReason {
RootNotDirectory,
CandidateReadFailed,
MissingManifest,
InvalidManifest,
UnsupportedVersion,
MissingProgram,
NonGameAppMode,
}
#[derive(Debug, Clone, Default)]
pub struct GameLibrary {
entries: Vec<GameLibraryEntry>,
diagnostics: Vec<GameLibraryDiagnostic>,
}
impl GameLibrary {
pub fn empty() -> Self {
Self::default()
}
pub fn entries(&self) -> &[GameLibraryEntry] {
&self.entries
}
pub fn diagnostics(&self) -> &[GameLibraryDiagnostic] {
&self.diagnostics
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
pub fn discover_games_root(root: impl AsRef<Path>) -> GameLibrary {
let root = root.as_ref();
let discovered_at = SystemTime::now();
if !root.is_dir() {
return GameLibrary {
entries: Vec::new(),
diagnostics: vec![GameLibraryDiagnostic {
path: root.to_path_buf(),
reason: GameLibraryDiagnosticReason::RootNotDirectory,
}],
};
}
let read_dir = match fs::read_dir(root) {
Ok(read_dir) => read_dir,
Err(_) => {
return GameLibrary {
entries: Vec::new(),
diagnostics: vec![GameLibraryDiagnostic {
path: root.to_path_buf(),
reason: GameLibraryDiagnosticReason::CandidateReadFailed,
}],
};
}
};
let mut entries = Vec::new();
let mut diagnostics = Vec::new();
for item in read_dir {
let Ok(item) = item else {
diagnostics.push(GameLibraryDiagnostic {
path: root.to_path_buf(),
reason: GameLibraryDiagnosticReason::CandidateReadFailed,
});
continue;
};
let path = item.path();
if !path.is_dir() {
continue;
}
match discover_candidate(&path, discovered_at) {
Ok(entry) => entries.push(entry),
Err(reason) => diagnostics.push(GameLibraryDiagnostic { path, reason }),
}
}
entries.sort_by(|left, right| {
left.title
.cmp(&right.title)
.then_with(|| left.app_id.cmp(&right.app_id))
.then_with(|| left.path.cmp(&right.path))
});
GameLibrary { entries, diagnostics }
}
fn discover_candidate(
path: &Path,
discovered_at: SystemTime,
) -> Result<GameLibraryEntry, GameLibraryDiagnosticReason> {
let manifest_path = path.join("manifest.json");
if !manifest_path.exists() {
return Err(GameLibraryDiagnosticReason::MissingManifest);
}
let manifest_content = fs::read_to_string(manifest_path)
.map_err(|_| GameLibraryDiagnosticReason::InvalidManifest)?;
let manifest: CartridgeManifest = serde_json::from_str(&manifest_content)
.map_err(|_| GameLibraryDiagnosticReason::InvalidManifest)?;
if manifest.magic != "PMTU" {
return Err(GameLibraryDiagnosticReason::InvalidManifest);
}
if manifest.cartridge_version != 1 {
return Err(GameLibraryDiagnosticReason::UnsupportedVersion);
}
if manifest.app_mode != AppMode::Game {
return Err(GameLibraryDiagnosticReason::NonGameAppMode);
}
if !path.join("program.pbx").exists() {
return Err(GameLibraryDiagnosticReason::MissingProgram);
}
Ok(GameLibraryEntry::new(path.to_path_buf(), manifest, discovered_at))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::UNIX_EPOCH;
static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
struct TestRoot {
path: PathBuf,
}
impl TestRoot {
fn new() -> Self {
let unique = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"prometeu-game-library-test-{}-{}",
std::process::id(),
unique
));
fs::create_dir_all(&path).expect("test root should be created");
Self { path }
}
fn add_candidate(&self, name: &str, manifest: serde_json::Value, with_program: bool) {
let candidate = self.path.join(name);
fs::create_dir_all(&candidate).expect("candidate should be created");
fs::write(candidate.join("manifest.json"), manifest.to_string())
.expect("manifest should be written");
if with_program {
fs::write(candidate.join("program.pbx"), [0_u8])
.expect("program should be written");
}
}
}
impl Drop for TestRoot {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn manifest(title: &str, app_id: u32, app_mode: &str) -> serde_json::Value {
json!({
"magic": "PMTU",
"cartridge_version": 1,
"app_id": app_id,
"title": title,
"app_version": "1.0.0",
"app_mode": app_mode
})
}
#[test]
fn discovery_returns_only_immediate_valid_game_directories() {
let root = TestRoot::new();
root.add_candidate("zeta", manifest("Zeta", 2, "Game"), true);
root.add_candidate("alpha", manifest("Alpha", 1, "Game"), true);
root.add_candidate("shell", manifest("Shell", 3, "Shell"), true);
root.add_candidate("missing-program", manifest("Missing Program", 4, "Game"), false);
fs::write(root.path.join("loose-file.txt"), "ignored").expect("file should be written");
let nested_parent = root.path.join("nested-parent");
fs::create_dir_all(nested_parent.join("nested-game")).expect("nested dir should exist");
fs::write(
nested_parent.join("nested-game").join("manifest.json"),
manifest("Nested", 5, "Game").to_string(),
)
.expect("nested manifest should be written");
fs::write(nested_parent.join("nested-game").join("program.pbx"), [0_u8])
.expect("nested program should be written");
let library = discover_games_root(&root.path);
assert_eq!(library.entries().len(), 2);
assert_eq!(library.entries()[0].title, "Alpha");
assert_eq!(library.entries()[0].app_id, 1);
assert_eq!(library.entries()[0].app_version, "1.0.0");
assert_eq!(library.entries()[0].manifest.app_mode, AppMode::Game);
assert!(library.entries()[0].path.ends_with("alpha"));
assert!(library.entries()[0].discovered_at >= UNIX_EPOCH);
assert_eq!(library.entries()[1].title, "Zeta");
assert!(library.diagnostics().iter().any(|diagnostic| {
diagnostic.path.ends_with("shell")
&& diagnostic.reason == GameLibraryDiagnosticReason::NonGameAppMode
}));
assert!(library.diagnostics().iter().any(|diagnostic| {
diagnostic.path.ends_with("missing-program")
&& diagnostic.reason == GameLibraryDiagnosticReason::MissingProgram
}));
assert!(library.diagnostics().iter().any(|diagnostic| {
diagnostic.path.ends_with("nested-parent")
&& diagnostic.reason == GameLibraryDiagnosticReason::MissingManifest
}));
}
#[test]
fn missing_root_returns_diagnostic_and_no_entries() {
let root = TestRoot::new();
let missing = root.path.join("missing");
let library = discover_games_root(&missing);
assert!(library.is_empty());
assert_eq!(library.diagnostics().len(), 1);
assert_eq!(library.diagnostics()[0].reason, GameLibraryDiagnosticReason::RootNotDirectory);
}
}

View File

@ -1,5 +1,6 @@
pub mod async_work;
pub mod fs;
pub mod game_library;
pub mod memcard;
pub mod process;
pub mod task;

View File

@ -33,6 +33,10 @@ struct Cli {
#[arg(long, value_name = "PATH")]
fs_root: Option<String>,
/// Root directory for local game cartridge discovery
#[arg(long, value_name = "PATH")]
games_root: Option<String>,
/// Path to the capability configuration file
#[arg(long, value_name = "PATH")]
cap: Option<String>,
@ -55,6 +59,9 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let event_loop = EventLoop::new()?;
let mut runner = HostRunner::new(fs_root, cap_config);
if let Some(games_root) = cli.games_root {
runner.set_games_root(games_root);
}
runner.set_boot_target(boot_target);
event_loop.run_app(&mut runner)?;

View File

@ -12,7 +12,7 @@ use prometeu_drivers::{AudioCommand, LocalFramebufferRenderBackend, MemoryBanks}
use prometeu_firmware::{BootTarget, Firmware, FirmwareState};
use prometeu_hal::RuntimePlatform;
use prometeu_hal::telemetry::CertificationConfig;
use prometeu_system::{LatestRenderFrameStore, RenderWorkerConfig};
use prometeu_system::{LatestRenderFrameStore, RenderWorkerConfig, discover_games_root};
use std::sync::Arc;
use std::time::{Duration, Instant};
use winit::application::ApplicationHandler;
@ -151,6 +151,18 @@ impl HostRunner {
self.debugger.setup_boot_target(&boot_target, &mut self.firmware);
}
pub(crate) fn set_games_root(&mut self, games_root: String) {
let library = discover_games_root(&games_root);
for diagnostic in library.diagnostics() {
eprintln!(
"games-root: omitted candidate {}: {:?}",
diagnostic.path.display(),
diagnostic.reason
);
}
self.firmware.hub.set_game_library(library);
}
/// Creates a new desktop runner instance.
pub(crate) fn new(fs_root: Option<String>, cap_config: Option<CertificationConfig>) -> Self {
let target_fps = 60;

View File

@ -14,7 +14,7 @@
{"type":"discussion","id":"DSC-0001","status":"done","ticket":"legacy-runtime-learn-import","title":"Import legacy runtime learn into discussion lessons","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["migration","tech-debt"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0001-prometeu-learn-index.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0002","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0002-historical-asset-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0003","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0003-historical-audio-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0004","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0004-historical-cartridge-boot-protocol-and-manifest-authority.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0005","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0005-historical-game-memcard-slots-surface-and-semantics.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0006","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0006-historical-gfx-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0007","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0007-historical-retired-fault-and-input-decisions.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0008","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0008-historical-vm-core-and-assets.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0009","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0009-mental-model-asset-management.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0010","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0010-mental-model-audio.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0011","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0011-mental-model-gfx.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0012","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0012-mental-model-input.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0013","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0013-mental-model-observability-and-debugging.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0014","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0014-mental-model-portability-and-cross-platform.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0015","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0015-mental-model-save-memory-and-memcard.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0016","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0016-mental-model-status-first-and-fault-thinking.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0017","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0017-mental-model-time-and-cycles.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0018","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0018-mental-model-touch.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
{"type":"discussion","id":"DSC-0002","status":"done","ticket":"runtime-edge-test-plan","title":"Agenda - Runtime Edge Test Plan","created_at":"2026-03-27","updated_at":"2026-04-21","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0037","file":"discussion/lessons/DSC-0002-runtime-edge-test-plan/LSN-0037-domain-gates-must-be-owned-by-the-repository.md","status":"done","created_at":"2026-04-21","updated_at":"2026-04-21"}]}
{"type":"discussion","id":"DSC-0003","status":"open","ticket":"packed-cartridge-loader-pmc","title":"Agenda - Packed Cartridge Loader PMC","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0002","file":"AGD-0002-packed-cartridge-loader-pmc.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0004","status":"in_progress","ticket":"system-run-cart","title":"Agenda - System Run Cart","created_at":"2026-03-27","updated_at":"2026-07-03","tags":[],"agendas":[{"id":"AGD-0003","file":"AGD-0003-system-run-cart.md","status":"accepted","created_at":"2026-03-27","updated_at":"2026-07-03"},{"id":"AGD-0045","file":"AGD-0045-systemos-library-root-home-game-launch.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-03"}],"decisions":[{"id":"DEC-0035","file":"DEC-0035-remove-userland-system-run-cart-and-preserve-direct-boot.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-03","ref_agenda":"AGD-0003"},{"id":"DEC-0036","file":"DEC-0036-systemos-games-root-and-home-game-launch.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-03","ref_agenda":"AGD-0045"}],"plans":[{"id":"PLN-0129","file":"PLN-0129-remove-userland-run-cart-abi-surface.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0035"]},{"id":"PLN-0130","file":"PLN-0130-remove-runtime-run-cart-stub-dispatch.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0035"]},{"id":"PLN-0131","file":"PLN-0131-preserve-direct-cartridge-boot-coverage.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0035"]},{"id":"PLN-0132","file":"PLN-0132-specify-games-root-home-launch-contract.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]},{"id":"PLN-0133","file":"PLN-0133-implement-games-root-library-discovery.md","status":"open","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]},{"id":"PLN-0134","file":"PLN-0134-wire-home-game-list-launch-flow.md","status":"open","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]},{"id":"PLN-0135","file":"PLN-0135-validate-games-root-launch-end-to-end.md","status":"open","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0004","status":"in_progress","ticket":"system-run-cart","title":"Agenda - System Run Cart","created_at":"2026-03-27","updated_at":"2026-07-03","tags":[],"agendas":[{"id":"AGD-0003","file":"AGD-0003-system-run-cart.md","status":"accepted","created_at":"2026-03-27","updated_at":"2026-07-03"},{"id":"AGD-0045","file":"AGD-0045-systemos-library-root-home-game-launch.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-03"}],"decisions":[{"id":"DEC-0035","file":"DEC-0035-remove-userland-system-run-cart-and-preserve-direct-boot.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-03","ref_agenda":"AGD-0003"},{"id":"DEC-0036","file":"DEC-0036-systemos-games-root-and-home-game-launch.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-03","ref_agenda":"AGD-0045"}],"plans":[{"id":"PLN-0129","file":"PLN-0129-remove-userland-run-cart-abi-surface.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0035"]},{"id":"PLN-0130","file":"PLN-0130-remove-runtime-run-cart-stub-dispatch.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0035"]},{"id":"PLN-0131","file":"PLN-0131-preserve-direct-cartridge-boot-coverage.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0035"]},{"id":"PLN-0132","file":"PLN-0132-specify-games-root-home-launch-contract.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]},{"id":"PLN-0133","file":"PLN-0133-implement-games-root-library-discovery.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]},{"id":"PLN-0134","file":"PLN-0134-wire-home-game-list-launch-flow.md","status":"open","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]},{"id":"PLN-0135","file":"PLN-0135-validate-games-root-launch-end-to-end.md","status":"open","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0005","status":"open","ticket":"system-fault-semantics-and-control-surface","title":"Agenda - System Fault Semantics and Control Surface","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0004","file":"AGD-0004-system-fault-semantics-and-control-surface.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0006","status":"open","ticket":"vm-owned-random-service","title":"Agenda - VM-Owned Random Service","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0005","file":"AGD-0005-vm-owned-random-service.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0007","status":"open","ticket":"app-home-filesystem-surface-and-semantics","title":"Agenda - App Home Filesystem Surface and Semantics","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0006","file":"AGD-0006-app-home-filesystem-surface-and-semantics.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}

View File

@ -2,7 +2,7 @@
id: PLN-0133
ticket: system-run-cart
title: Implement Games Root Library Discovery
status: open
status: done
created: 2026-07-03
ref_decisions: [DEC-0036]
tags: []