diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-22 12:12:27 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-22 12:13:32 +0300 |
| commit | d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch) | |
| tree | a0bd35c3940be62a5b5de1acc2366af377ffd181 /apps | |
| parent | 7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff) | |
| download | fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip | |
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/fparkan-cli/Cargo.toml | 18 | ||||
| -rw-r--r-- | apps/fparkan-cli/src/main.rs | 346 | ||||
| -rw-r--r-- | apps/fparkan-game/Cargo.toml | 15 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 322 | ||||
| -rw-r--r-- | apps/fparkan-headless/Cargo.toml | 14 | ||||
| -rw-r--r-- | apps/fparkan-headless/src/main.rs | 114 | ||||
| -rw-r--r-- | apps/fparkan-viewer/Cargo.toml | 19 | ||||
| -rw-r--r-- | apps/fparkan-viewer/src/main.rs | 353 |
8 files changed, 1201 insertions, 0 deletions
diff --git a/apps/fparkan-cli/Cargo.toml b/apps/fparkan-cli/Cargo.toml new file mode 100644 index 0000000..22952e6 --- /dev/null +++ b/apps/fparkan-cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "fparkan-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-corpus = { path = "../../crates/fparkan-corpus" } +fparkan-nres = { path = "../../crates/fparkan-nres" } +fparkan-prototype = { path = "../../crates/fparkan-prototype" } +fparkan-resource = { path = "../../crates/fparkan-resource" } +fparkan-rsli = { path = "../../crates/fparkan-rsli" } +fparkan-runtime = { path = "../../crates/fparkan-runtime" } +fparkan-vfs = { path = "../../crates/fparkan-vfs" } + +[lints] +workspace = true diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs new file mode 100644 index 0000000..6f9f0f6 --- /dev/null +++ b/apps/fparkan-cli/src/main.rs @@ -0,0 +1,346 @@ +#![forbid(unsafe_code)] +#![allow(clippy::print_stderr, clippy::print_stdout)] +//! `FParkan` command-line tools. + +use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions}; +use fparkan_prototype::{ + build_prototype_graph_report, extend_graph_report_with_visual_dependencies, +}; +use fparkan_resource::{resource_name, CachedResourceRepository}; +use fparkan_runtime::{ + create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest, +}; +use fparkan_vfs::DirectoryVfs; +use std::fmt::Write; +use std::path::PathBuf; +use std::sync::Arc; + +fn main() { + let args: Vec<String> = std::env::args().skip(1).collect(); + let result = run(&args); + let code = exit_code(&result); + if let Err(err) = result { + eprintln!("{err}"); + } + std::process::exit(code); +} + +fn run(args: &[String]) -> Result<(), String> { + match args { + [domain, command, rest @ ..] if domain == "corpus" && command == "discover" => { + let rest = strip_format_json(rest)?; + let root = parse_root(&rest)?; + let manifest = + discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?; + let report = report(&root, &manifest); + println!("{}", render_report_json(&report)); + Ok(()) + } + [domain, command, rest @ ..] if domain == "corpus" && command == "validate" => { + let rest = strip_format_json(rest)?; + let root = parse_root(&rest)?; + let manifest = + discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?; + let report = report(&root, &manifest); + if report.casefold_collisions > 0 { + return Err("casefold collisions found".to_string()); + } + println!("{}", render_report_json(&report)); + Ok(()) + } + [domain, command, rest @ ..] if domain == "archive" && command == "inspect" => { + let rest = strip_format_json(rest)?; + inspect_archive(&rest) + } + [domain, command, rest @ ..] if domain == "prototype" && command == "inspect" => { + let rest = strip_format_json(rest)?; + inspect_prototype(&rest) + } + [domain, command, rest @ ..] if domain == "mission" && command == "graph" => { + let rest = strip_format_json(rest)?; + graph_mission(&rest) + } + _ => Err(usage()), + } +} + +fn exit_code(result: &Result<(), String>) -> i32 { + if result.is_ok() { + 0 + } else { + 2 + } +} + +fn strip_format_json(args: &[String]) -> Result<Vec<String>, String> { + let mut stripped = Vec::with_capacity(args.len()); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == "--format" { + let value = iter + .next() + .ok_or_else(|| "--format requires a value".to_string())?; + if value != "json" { + return Err(format!("unsupported output format: {value}")); + } + continue; + } + stripped.push(arg.clone()); + } + Ok(stripped) +} + +fn parse_root(args: &[String]) -> Result<PathBuf, String> { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == "--root" { + return iter + .next() + .map(PathBuf::from) + .ok_or_else(|| "--root requires a path".to_string()); + } + } + Err("missing --root".to_string()) +} + +fn parse_root_alias(args: &[String]) -> Result<PathBuf, String> { + parse_option(args, &["--root", "--game-root"]) + .map(PathBuf::from) + .ok_or_else(|| "missing --root".to_string()) +} + +fn parse_required(args: &[String], names: &[&str], label: &str) -> Result<String, String> { + parse_option(args, names).ok_or_else(|| format!("missing {label}")) +} + +fn parse_option(args: &[String], names: &[&str]) -> Option<String> { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if names.iter().any(|name| arg == name) { + return iter.next().cloned(); + } + } + None +} + +fn inspect_prototype(args: &[String]) -> Result<(), String> { + let root = parse_root_alias(args)?; + let key = parse_required(args, &["--key"], "--key")?; + let vfs = Arc::new(DirectoryVfs::new(root)); + let repository = CachedResourceRepository::new(vfs.clone()); + let roots = [resource_name(key.as_bytes())]; + let (graph, resolved, mut report) = + build_prototype_graph_report(&repository, vfs.as_ref(), &roots); + extend_graph_report_with_visual_dependencies(&repository, &mut report, &resolved); + println!("{}", prototype_inspect_json(&key, &graph, &report)); + Ok(()) +} + +fn prototype_inspect_json( + key: &str, + graph: &fparkan_prototype::PrototypeGraph, + report: &fparkan_prototype::PrototypeGraphReport, +) -> String { + format!( + "{{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":{},\"roots\":{},\"prototype_requests\":{},\"resolved\":{},\"unit_references\":{},\"unit_components\":{},\"direct_references\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}", + json_string(key), + report.root_count, + graph.prototype_requests.len(), + report.resolved_count, + report.unit_reference_count, + report.unit_component_count, + report.direct_reference_count, + report.wear_resolved_count, + report.material_resolved_count, + report.texture_resolved_count, + report.lightmap_resolved_count, + report.failures.len() + ) +} + +fn graph_mission(args: &[String]) -> Result<(), String> { + let root = parse_root_alias(args)?; + let mission = parse_required(args, &["--mission"], "--mission")?; + let services = EngineServices::new(Arc::new(DirectoryVfs::new(root))); + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + services, + ) + .map_err(|err| err.to_string())?; + let loaded = load_mission( + &mut engine, + MissionRequest { + key: mission.clone(), + }, + ) + .map_err(|err| err.to_string())?; + println!( + "{{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":{},\"objects\":{},\"paths\":{},\"clans\":{},\"extras\":{},\"roots\":{},\"direct_references\":{},\"unit_references\":{},\"unit_components\":{},\"prototype_requests\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}", + json_string(&mission), + loaded.object_count, + loaded.path_count, + loaded.clan_count, + loaded.extra_count, + loaded.graph_root_count, + loaded.graph_direct_reference_count, + loaded.graph_unit_reference_count, + loaded.graph_unit_component_count, + loaded.graph_resolved_count, + loaded.graph_wear_resolved_count, + loaded.graph_material_resolved_count, + loaded.graph_texture_resolved_count, + loaded.graph_lightmap_resolved_count, + loaded.graph_failure_count + ); + Ok(()) +} + +fn inspect_archive(args: &[String]) -> Result<(), String> { + let path = parse_archive_path(args)?; + let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?; + if bytes.starts_with(b"NRes") { + let document = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + fparkan_nres::ReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + println!( + "{}", + archive_inspect_json( + &path.display().to_string(), + "NRes", + document.entries().len(), + Some(document.lookup_order_valid()), + ) + ); + return Ok(()); + } + if bytes.get(0..4) == Some(b"NL\0\x01") { + let document = fparkan_rsli::decode( + Arc::from(bytes.into_boxed_slice()), + fparkan_rsli::ReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + println!( + "{}", + archive_inspect_json( + &path.display().to_string(), + "RsLi", + document.entries().len(), + None + ) + ); + return Ok(()); + } + Err(format!("{}: unsupported archive magic", path.display())) +} + +fn archive_inspect_json( + path: &str, + kind: &str, + entries: usize, + lookup_order_valid: Option<bool>, +) -> String { + let mut out = format!( + "{{\"schema_version\":\"fparkan-archive-inspect-v1\",\"path\":{},\"kind\":{},\"entries\":{}", + json_string(path), + json_string(kind), + entries + ); + if let Some(valid) = lookup_order_valid { + let _ = write!(out, ",\"lookup_order_valid\":{valid}"); + } + out.push('}'); + out +} + +fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> { + match args { + [path] => Ok(PathBuf::from(path)), + [flag, path] if flag == "--file" => Ok(PathBuf::from(path)), + _ => Err("archive inspect requires <file> or --file <file>".to_string()), + } +} + +fn json_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => { + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +fn usage() -> String { + "usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph --root <path> --mission <path> [--format json]".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec<String> { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn stable_exit_codes_are_mapped() { + assert_eq!(exit_code(&Ok(())), 0); + assert_eq!(exit_code(&Err("failure".to_string())), 2); + } + + #[test] + fn accepts_json_format_option() { + assert_eq!( + strip_format_json(&strings(&["--root", "testdata", "--format", "json"])), + Ok(strings(&["--root", "testdata"])) + ); + assert_eq!( + strip_format_json(&strings(&["--format", "text"])), + Err("unsupported output format: text".to_string()) + ); + } + + #[test] + fn archive_json_has_schema_version() { + let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true)); + + assert!(json.contains("\"schema_version\":\"fparkan-archive-inspect-v1\"")); + assert!(json.contains("\"kind\":\"NRes\"")); + assert!(json.contains("\"lookup_order_valid\":true")); + } + + #[test] + fn prototype_graph_json_has_canonical_field_order() { + let mut graph = fparkan_prototype::PrototypeGraph::default(); + graph + .prototype_requests + .push(fparkan_prototype::PrototypeKey(resource_name(b"root"))); + let report = fparkan_prototype::PrototypeGraphReport { + root_count: 1, + direct_reference_count: 1, + resolved_count: 1, + ..fparkan_prototype::PrototypeGraphReport::default() + }; + + let json = prototype_inspect_json("root", &graph, &report); + + assert_eq!( + json, + "{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":\"root\",\"roots\":1,\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"failures\":0}" + ); + } +} diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml new file mode 100644 index 0000000..eef4d81 --- /dev/null +++ b/apps/fparkan-game/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "fparkan-game" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-render = { path = "../../crates/fparkan-render" } +fparkan-runtime = { path = "../../crates/fparkan-runtime" } +fparkan-vfs = { path = "../../crates/fparkan-vfs" } +fparkan-world = { path = "../../crates/fparkan-world" } + +[lints] +workspace = true diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs new file mode 100644 index 0000000..ed12c70 --- /dev/null +++ b/apps/fparkan-game/src/main.rs @@ -0,0 +1,322 @@ +#![forbid(unsafe_code)] +#![allow(clippy::print_stderr, clippy::print_stdout)] +//! `FParkan` rendered game composition root. + +use fparkan_render::{ + DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RecordingBackend, RenderBackend, + RenderCommand, RenderCommandList, RenderPhase, +}; +use fparkan_runtime::{ + create, frame, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest, +}; +use fparkan_vfs::DirectoryVfs; +use fparkan_world::WorldSnapshot; +use std::path::PathBuf; +use std::sync::Arc; + +fn main() { + let raw_args = std::env::args().skip(1).collect::<Vec<_>>(); + let code = match run(&raw_args) { + Ok(output) => { + println!("{output}"); + 0 + } + Err(err) => { + eprintln!("{err}"); + 2 + } + }; + std::process::exit(code); +} + +fn run(args: &[String]) -> Result<String, String> { + let args = Args::parse(args)?; + let services = EngineServices::new(Arc::new(DirectoryVfs::new(&args.root))); + let mut engine = create( + EngineConfig { + mode: EngineMode::Rendered, + }, + services, + ) + .map_err(|err| err.to_string())?; + let loaded = load_mission( + &mut engine, + MissionRequest { + key: args.mission.clone(), + }, + ) + .map_err(|err| err.to_string())?; + + let mut backend = RecordingBackend::default(); + let mut last_draw_count = 0usize; + let mut last_tick = 0u64; + let mut last_hash = [0u8; 32]; + for _ in 0..args.frames { + let result = frame(&mut engine).map_err(|err| err.to_string())?; + last_tick = result.snapshot.tick.0; + last_hash = result.snapshot.hash.0; + let commands = render_snapshot_commands(&result.snapshot); + last_draw_count = commands + .commands + .iter() + .filter(|command| matches!(command, RenderCommand::Draw(_))) + .count(); + backend + .execute(&commands) + .map_err(|err| format!("render backend: {err}"))?; + } + + Ok(format!( + "{{\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"captures\":{},\"last_capture_bytes\":{},\"hash\":{}}}", + json_string(&args.mission), + loaded.object_count, + args.frames, + last_tick, + last_draw_count, + backend.captures().len(), + backend.last_capture().map_or(0, <[u8]>::len), + json_hash(&last_hash) + )) +} + +fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList { + let mut commands = Vec::with_capacity(snapshot.objects.len() + 2); + commands.push(RenderCommand::BeginFrame); + for (index, handle) in snapshot.objects.iter().enumerate() { + let stable_order = u64::from(handle.slot); + let draw_id = snapshot + .tick + .0 + .wrapping_mul(1_000_003) + .wrapping_add(stable_order); + commands.push(RenderCommand::Draw(DrawCommand { + id: DrawId(draw_id), + phase: RenderPhase::Opaque, + object_id: None, + mesh: GpuMeshId(u64::from(handle.slot) + 1), + material: GpuMaterialId(1), + transform: identity_transform(index_to_f32(index)), + range: IndexRange { start: 0, count: 3 }, + stable_order, + })); + } + commands.push(RenderCommand::EndFrame); + RenderCommandList { commands } +} + +fn identity_transform(x: f32) -> [f32; 16] { + [ + 1.0, 0.0, 0.0, x, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ] +} + +fn index_to_f32(index: usize) -> f32 { + u16::try_from(index).map_or(f32::from(u16::MAX), f32::from) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct Args { + root: PathBuf, + mission: String, + frames: u64, +} + +impl Args { + fn parse(args: &[String]) -> Result<Self, String> { + let mut root = None; + let mut mission = None; + let mut frames = 1; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--root" => { + root = Some( + iter.next() + .map(PathBuf::from) + .ok_or_else(|| "--root requires a path".to_string())?, + ); + } + "--mission" => { + mission = Some( + iter.next() + .cloned() + .ok_or_else(|| "--mission requires a path".to_string())?, + ); + } + "--frames" => { + frames = iter + .next() + .ok_or_else(|| "--frames requires a value".to_string())? + .parse() + .map_err(|_| "--frames must be an integer".to_string())?; + } + _ => return Err(usage()), + } + } + let root = root.ok_or_else(|| "missing --root".to_string())?; + let mission = mission.ok_or_else(|| "missing --mission".to_string())?; + if frames == 0 { + return Err("--frames must be greater than zero".to_string()); + } + Ok(Self { + root, + mission, + frames, + }) + } +} + +fn json_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +fn json_hash(hash: &[u8; 32]) -> String { + let mut out = String::from("\""); + for byte in hash { + use std::fmt::Write as _; + let _ = write!(out, "{byte:02x}"); + } + out.push('"'); + out +} + +fn usage() -> String { + "usage: fparkan-game --root <path> --mission <path> [--frames <n>]".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_world::{ObjectHandle, StateHash, Tick}; + use std::path::Path; + + fn strings(values: &[&str]) -> Vec<String> { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn parses_required_args() { + assert_eq!( + Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--frames", + "3", + ])), + Ok(Args { + root: PathBuf::from("testdata/IS"), + mission: "MISSIONS/Autodemo.00/data.tma".to_string(), + frames: 3, + }) + ); + } + + #[test] + fn render_commands_follow_snapshot_order() -> Result<(), String> { + let snapshot = WorldSnapshot { + tick: Tick(7), + objects: vec![ + ObjectHandle { + generation: 1, + slot: 2, + }, + ObjectHandle { + generation: 1, + slot: 5, + }, + ], + events: Vec::new(), + hash: StateHash([0; 32]), + }; + + let commands = render_snapshot_commands(&snapshot); + + assert_eq!(commands.commands.len(), 4); + assert!(matches!(commands.commands[0], RenderCommand::BeginFrame)); + assert!(matches!(commands.commands[3], RenderCommand::EndFrame)); + let RenderCommand::Draw(first) = &commands.commands[1] else { + return Err("expected draw".to_string()); + }; + assert_eq!(first.mesh, GpuMeshId(3)); + assert_eq!(first.stable_order, 2); + Ok(()) + } + + #[test] + fn selected_is_and_is2_missions_produce_approved_render_captures() { + for case in [ + RenderCase { + root: "IS", + mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma", + expected: "{\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"captures\":1,\"last_capture_bytes\":810,\"hash\":\"8584c4307bc911fc82bf909018662f392f3982bf909018666298bde408fe4242\"}", + }, + RenderCase { + root: "IS2", + mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma", + expected: "{\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"captures\":1,\"last_capture_bytes\":235,\"hash\":\"c52267cb14f699cb73b958e46c99c23ec23e73b958e46c99b3650afbcce56291\"}", + }, + ] { + assert_eq!( + run(&render_args(&workspace_root().join("testdata").join(case.root), case.mission)), + Ok(case.expected.to_string()) + ); + } + } + + #[test] + fn json_hash_is_hex() { + let mut hash = [0; 32]; + hash[0] = 0xab; + hash[31] = 0xcd; + + assert_eq!( + json_hash(&hash), + "\"ab000000000000000000000000000000000000000000000000000000000000cd\"" + ); + } + + #[derive(Clone, Copy)] + struct RenderCase { + root: &'static str, + mission: &'static str, + expected: &'static str, + } + + fn render_args(root: &Path, mission: &str) -> Vec<String> { + vec![ + "--root".to_string(), + root.to_str().expect("utf8 root").to_string(), + "--mission".to_string(), + mission.to_string(), + "--frames".to_string(), + "1".to_string(), + ] + } + + fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root") + .to_path_buf() + } +} diff --git a/apps/fparkan-headless/Cargo.toml b/apps/fparkan-headless/Cargo.toml new file mode 100644 index 0000000..ecf2948 --- /dev/null +++ b/apps/fparkan-headless/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "fparkan-headless" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-runtime = { path = "../../crates/fparkan-runtime" } +fparkan-vfs = { path = "../../crates/fparkan-vfs" } +fparkan-world = { path = "../../crates/fparkan-world" } + +[lints] +workspace = true diff --git a/apps/fparkan-headless/src/main.rs b/apps/fparkan-headless/src/main.rs new file mode 100644 index 0000000..b78a7dc --- /dev/null +++ b/apps/fparkan-headless/src/main.rs @@ -0,0 +1,114 @@ +#![forbid(unsafe_code)] +#![allow(clippy::print_stderr, clippy::print_stdout)] +//! `FParkan` headless runtime entrypoint. + +use fparkan_runtime::{ + create, load_mission, step_headless, EngineConfig, EngineMode, EngineServices, MissionRequest, +}; +use fparkan_vfs::DirectoryVfs; +use fparkan_world::InputSnapshot; +use std::path::PathBuf; +use std::sync::Arc; + +fn main() { + if let Err(err) = run() { + eprintln!("{err}"); + std::process::exit(2); + } +} + +fn run() -> Result<(), String> { + let raw_args: Vec<String> = std::env::args().skip(1).collect(); + let args = Args::parse(&raw_args)?; + let services = if let Some(root) = &args.root { + EngineServices::new(Arc::new(DirectoryVfs::new(root))) + } else { + EngineServices::default() + }; + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + services, + ) + .map_err(|err| format!("{err}"))?; + if let Some(mission) = args.mission { + let loaded = load_mission(&mut engine, MissionRequest { key: mission }) + .map_err(|err| format!("{err}"))?; + println!( + "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} graph_failures={}", + loaded.object_count, + loaded.areal_count, + loaded.surface_count, + loaded.graph_root_count, + loaded.graph_unit_component_count, + loaded.graph_wear_resolved_count, + loaded.graph_material_resolved_count, + loaded.graph_texture_resolved_count, + loaded.graph_lightmap_resolved_count, + loaded.graph_failure_count + ); + } + let mut last = None; + for _ in 0..args.ticks { + last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?); + } + if let Some(frame) = last { + println!( + "tick={} hash={:02x?}", + frame.snapshot.tick.0, frame.snapshot.hash.0 + ); + } + Ok(()) +} + +struct Args { + root: Option<PathBuf>, + mission: Option<String>, + ticks: u64, +} + +impl Args { + fn parse(args: &[String]) -> Result<Self, String> { + let mut parsed = Self { + root: None, + mission: None, + ticks: 1, + }; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--root" => { + parsed.root = Some( + iter.next() + .map(PathBuf::from) + .ok_or_else(|| "--root requires a path".to_string())?, + ); + } + "--mission" => { + parsed.mission = Some( + iter.next() + .cloned() + .ok_or_else(|| "--mission requires a path".to_string())?, + ); + } + "--ticks" => { + parsed.ticks = iter + .next() + .ok_or_else(|| "--ticks requires a value".to_string())? + .parse() + .map_err(|_| "--ticks must be an integer".to_string())?; + } + _ => return Err(usage()), + } + } + if parsed.mission.is_some() && parsed.root.is_none() { + return Err("--mission requires --root".to_string()); + } + Ok(parsed) + } +} + +fn usage() -> String { + "usage: fparkan-headless [--root <path> --mission <path>] [--ticks <n>]".to_string() +} diff --git a/apps/fparkan-viewer/Cargo.toml b/apps/fparkan-viewer/Cargo.toml new file mode 100644 index 0000000..4219e8a --- /dev/null +++ b/apps/fparkan-viewer/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "fparkan-viewer" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-msh = { path = "../../crates/fparkan-msh" } +fparkan-nres = { path = "../../crates/fparkan-nres" } +fparkan-resource = { path = "../../crates/fparkan-resource" } +fparkan-render = { path = "../../crates/fparkan-render" } +fparkan-rsli = { path = "../../crates/fparkan-rsli" } +fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" } +fparkan-texm = { path = "../../crates/fparkan-texm" } +fparkan-vfs = { path = "../../crates/fparkan-vfs" } + +[lints] +workspace = true diff --git a/apps/fparkan-viewer/src/main.rs b/apps/fparkan-viewer/src/main.rs new file mode 100644 index 0000000..1720cd7 --- /dev/null +++ b/apps/fparkan-viewer/src/main.rs @@ -0,0 +1,353 @@ +#![forbid(unsafe_code)] +#![allow(clippy::print_stderr, clippy::print_stdout)] +//! `FParkan` asset viewer composition root. + +use fparkan_msh::{decode_msh, validate_msh}; +use fparkan_nres::{decode as decode_nres, ReadProfile as NresReadProfile}; +use fparkan_render::{ + build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase, + RenderProfile, RenderSnapshot, RenderSnapshotDraw, +}; +use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository}; +use fparkan_terrain_format::{decode_land_map, decode_land_msh}; +use fparkan_texm::decode_texm; +use fparkan_vfs::DirectoryVfs; +use std::fmt::Write; +use std::path::PathBuf; +use std::sync::Arc; + +fn main() { + let args = std::env::args().skip(1).collect::<Vec<_>>(); + let code = match run(&args) { + Ok(json) => { + println!("{json}"); + 0 + } + Err(err) => { + eprintln!("{err}"); + 2 + } + }; + std::process::exit(code); +} + +fn run(args: &[String]) -> Result<String, String> { + match args { + [domain, rest @ ..] if domain == "archive" => inspect_archive(rest), + [domain, rest @ ..] if domain == "model" => inspect_model(rest), + [domain, rest @ ..] if domain == "texture" => inspect_texture(rest), + [domain, rest @ ..] if domain == "map" => inspect_map(rest), + _ => Err(usage()), + } +} + +fn inspect_archive(args: &[String]) -> Result<String, String> { + let file = parse_file(args)?; + let limit = parse_limit(args)?; + let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?; + if bytes.starts_with(b"NRes") { + let document = decode_nres( + Arc::from(bytes.into_boxed_slice()), + NresReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + let sample = render_nres_entries(&document, limit); + return Ok(format!( + "{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}", + json_string(&file.display().to_string()), + document.entries().len(), + document.lookup_order_valid(), + sample + )); + } + if bytes.get(0..4) == Some(b"NL\0\x01") { + let document = fparkan_rsli::decode( + Arc::from(bytes.into_boxed_slice()), + fparkan_rsli::ReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + return Ok(format!( + "{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}", + json_string(&file.display().to_string()), + document.entries().len() + )); + } + Err(format!("{}: unsupported archive magic", file.display())) +} + +fn inspect_model(args: &[String]) -> Result<String, String> { + if let Some(fixture) = parse_option(args, &["--fixture"]) { + return ViewerModelService::inspect_synthetic_model(&fixture); + } + + let query = parse_resource_query(args)?; + let bytes = read_resource(&query)?; + let nested = decode_nres(bytes, NresReadProfile::Compatible).map_err(|err| err.to_string())?; + let document = decode_msh(&nested).map_err(|err| err.to_string())?; + let model = validate_msh(&document).map_err(|err| err.to_string())?; + + Ok(format!( + "{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}", + json_string(&query.archive), + json_string(&query.name), + document.streams().len(), + model.node_count, + model.slots.len(), + model.positions.len(), + model.indices.len(), + model.batches.len() + )) +} + +#[derive(Clone, Debug)] +struct ViewerModelService; + +impl ViewerModelService { + fn inspect_synthetic_model(fixture: &str) -> Result<String, String> { + if fixture != "synthetic/model-basic" { + return Err(format!("unknown model fixture: {fixture}")); + } + + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![RenderSnapshotDraw { + id: DrawId(1), + phase: RenderPhase::Opaque, + object_id: None, + mesh: GpuMeshId(1), + material_slots: vec![GpuMaterialId(7)], + material_index: 0, + transform: identity_transform(), + range: IndexRange { start: 0, count: 3 }, + stable_order: 0, + }], + }; + let commands = build_commands(&snapshot, RenderProfile::default()) + .map_err(|err| format!("render command generation: {err}"))?; + let draw_commands = commands + .commands + .iter() + .filter(|command| matches!(command, fparkan_render::RenderCommand::Draw(_))) + .count(); + + Ok(format!( + "{{\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model\",\"draw_commands\":{draw_commands}}}", + json_string(fixture) + )) + } +} + +fn inspect_texture(args: &[String]) -> Result<String, String> { + let query = parse_resource_query(args)?; + let document = decode_texm(read_resource(&query)?).map_err(|err| err.to_string())?; + + Ok(format!( + "{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}", + json_string(&query.archive), + json_string(&query.name), + document.width(), + document.height(), + json_string(&format!("{:?}", document.format())), + document.mip_count(), + document.page_rects().len() + )) +} + +fn inspect_map(args: &[String]) -> Result<String, String> { + let file = parse_file(args)?; + let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?; + let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?; + let nres = decode_nres( + Arc::from(bytes.into_boxed_slice()), + NresReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + + match kind.as_str() { + "land-msh" => { + let land = decode_land_msh(&nres).map_err(|err| err.to_string())?; + Ok(format!( + "{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}", + json_string(&file.display().to_string()), + land.streams.len(), + land.positions.len(), + land.faces.len(), + land.slots.slots_raw.len() + )) + } + "land-map" => { + let land = decode_land_map(&nres).map_err(|err| err.to_string())?; + Ok(format!( + "{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}", + json_string(&file.display().to_string()), + land.areals.len(), + land.areal_count, + land.grid.cells_x, + land.grid.cells_y + )) + } + _ => Err(format!("unknown map kind: {kind}")), + } +} + +struct ResourceQuery { + root: PathBuf, + archive: String, + name: String, +} + +fn parse_resource_query(args: &[String]) -> Result<ResourceQuery, String> { + Ok(ResourceQuery { + root: parse_path_option(args, &["--root", "--game-root"], "--root")?, + archive: parse_option(args, &["--archive"]) + .ok_or_else(|| "missing --archive".to_string())?, + name: parse_option(args, &["--name"]).ok_or_else(|| "missing --name".to_string())?, + }) +} + +fn read_resource(query: &ResourceQuery) -> Result<Arc<[u8]>, String> { + let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&query.root))); + let archive = repository + .open_archive(&archive_path(query.archive.as_bytes()).map_err(|err| err.to_string())?) + .map_err(|err| err.to_string())?; + let entry = repository + .find(archive, &resource_name(query.name.as_bytes())) + .map_err(|err| err.to_string())? + .ok_or_else(|| format!("resource not found: {}/{}", query.archive, query.name))?; + let bytes = repository.read(entry).map_err(|err| err.to_string())?; + Ok(Arc::from(bytes.into_owned())) +} + +fn parse_file(args: &[String]) -> Result<PathBuf, String> { + parse_path_option(args, &["--file"], "--file") +} + +fn parse_limit(args: &[String]) -> Result<usize, String> { + parse_option(args, &["--limit"]) + .map(|value| { + value + .parse::<usize>() + .map_err(|_| format!("invalid --limit: {value}")) + }) + .transpose() + .map(|value| value.unwrap_or(0)) +} + +fn render_nres_entries(document: &fparkan_nres::NresDocument, limit: usize) -> String { + let mut out = String::new(); + for (index, entry) in document.entries().iter().take(limit).enumerate() { + if index > 0 { + out.push(','); + } + let name = String::from_utf8_lossy(entry.name_bytes()); + let _ = write!( + out, + "{{\"name\":{},\"type\":{},\"size\":{}}}", + json_string(&name), + entry.meta().type_id, + entry.meta().data_size + ); + } + out +} + +fn parse_path_option(args: &[String], names: &[&str], label: &str) -> Result<PathBuf, String> { + parse_option(args, names) + .map(PathBuf::from) + .ok_or_else(|| format!("missing {label}")) +} + +fn parse_option(args: &[String], names: &[&str]) -> Option<String> { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if names.iter().any(|name| arg == name) { + return iter.next().cloned(); + } + } + None +} + +fn json_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => { + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +fn identity_transform() -> [f32; 16] { + [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ] +} + +fn usage() -> String { + "usage: fparkan-viewer archive --file <archive> [--limit N] | model --root <game-root> --archive <archive> --name <msh> | model --fixture synthetic/model-basic | texture --root <game-root> --archive <archive> --name <texm> | map --file <Land.msh|Land.map> --kind land-msh|land-map".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec<String> { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn parses_resource_query() -> Result<(), String> { + let query = parse_resource_query(&strings(&[ + "--root", + "testdata/IS", + "--archive", + "textures.lib", + "--name", + "grass.tex", + ]))?; + + assert_eq!(query.root, PathBuf::from("testdata/IS")); + assert_eq!(query.archive, "textures.lib"); + assert_eq!(query.name, "grass.tex"); + Ok(()) + } + + #[test] + fn json_string_escapes_controls() { + assert_eq!(json_string("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\""); + } + + #[test] + fn usage_rejects_empty_args() { + assert_eq!(run(&[]), Err(usage())); + } + + #[test] + fn parses_limit() { + assert_eq!(parse_limit(&strings(&["--limit", "2"])), Ok(2)); + assert_eq!(parse_limit(&[]), Ok(0)); + assert_eq!( + parse_limit(&strings(&["--limit", "x"])), + Err("invalid --limit: x".to_string()) + ); + } + + #[test] + fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> { + assert_eq!( + run(&strings(&["model", "--fixture", "synthetic/model-basic"]))?, + "{\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model\",\"draw_commands\":1}" + ); + Ok(()) + } +} |
