diff options
| author | Valentin Popov <valentin@popov.link> | 2026-09-06 04:22:12 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-09-06 04:22:12 +0300 |
| commit | aa51f3574d3d4d4e0fa59653eedd718fceb5d1bd (patch) | |
| tree | 9a43f6a3f3c86767e4e2b7316051562d96789f06 /apps | |
| parent | da03e77f53bb33b2fe203ac45578289953a1b524 (diff) | |
| download | fparkan-aa51f3574d3d4d4e0fa59653eedd718fceb5d1bd.tar.xz fparkan-aa51f3574d3d4d4e0fa59653eedd718fceb5d1bd.zip | |
chore: simplify project to engine docs and tests
Remove planning and acceptance scaffolding while retaining the native Vulkan mission preview, format readers, runtime algorithms, and ordinary Rust tests. Keep the book aligned with the runnable project and validate checked-in shaders without generated tool metadata.
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/fparkan-cli/Cargo.toml | 22 | ||||
| -rw-r--r-- | apps/fparkan-cli/src/main.rs | 1070 | ||||
| -rw-r--r-- | apps/fparkan-game/Cargo.toml | 22 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 1041 | ||||
| -rw-r--r-- | apps/fparkan-headless/Cargo.toml | 14 | ||||
| -rw-r--r-- | apps/fparkan-headless/src/main.rs | 267 | ||||
| -rw-r--r-- | apps/fparkan-viewer/Cargo.toml | 13 | ||||
| -rw-r--r-- | apps/fparkan-viewer/src/main.rs | 350 | ||||
| -rw-r--r-- | apps/fparkan-vulkan-smoke/Cargo.toml | 19 | ||||
| -rw-r--r-- | apps/fparkan-vulkan-smoke/build.rs | 134 | ||||
| -rw-r--r-- | apps/fparkan-vulkan-smoke/src/main.rs | 1973 |
11 files changed, 103 insertions, 4822 deletions
diff --git a/apps/fparkan-cli/Cargo.toml b/apps/fparkan-cli/Cargo.toml deleted file mode 100644 index dcf9c12..0000000 --- a/apps/fparkan-cli/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "fparkan-cli" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" } -fparkan-corpus = { path = "../../crates/fparkan-corpus", version = "0.1.0" } -fparkan-prototype = { path = "../../crates/fparkan-prototype", version = "0.1.0" } -fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" } -fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" } -fparkan-resource = { path = "../../crates/fparkan-resource", version = "0.1.0" } -fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" } -fparkan-script = { path = "../../crates/fparkan-script", version = "0.1.0" } -fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -[lints] -workspace = true diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs deleted file mode 100644 index d3e1df5..0000000 --- a/apps/fparkan-cli/src/main.rs +++ /dev/null @@ -1,1070 +0,0 @@ -#![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] -#![allow(clippy::print_stderr, clippy::print_stdout)] -//! `FParkan` command-line tools. - -use fparkan_assets::{ - decode_mission_payload, extend_graph_report_with_visual_dependencies, TmaProfile, -}; -use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions}; -use fparkan_inspection::{ - inspect_archive_file, inspect_land_msh_bounds_file, inspect_model_from_root, - inspect_wear_from_root, load_land_msh_from_path, ArchiveInspection, ModelInspection, -}; -use fparkan_path::{normalize_relative, PathPolicy}; -use fparkan_prototype::build_prototype_graph_report; -use fparkan_resource::{resource_name, CachedResourceRepository}; -use fparkan_runtime::{ - create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest, -}; -use fparkan_vfs::{DirectoryVfs, Vfs}; -use serde::Serialize; -use std::fmt::Write as _; -use std::path::PathBuf; -use std::sync::Arc; - -const ARCHIVE_INSPECT_SCHEMA: &str = "fparkan-archive-inspect-v1"; -const PROTOTYPE_INSPECT_SCHEMA: &str = "fparkan-prototype-inspect-v2"; -const MISSION_GRAPH_SCHEMA: &str = "fparkan-mission-graph-v1"; -const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1"; -const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1"; -const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1"; -const WEAR_INSPECT_SCHEMA: &str = "fparkan-wear-inspect-v1"; -const SCRIPT_INSPECT_SCHEMA: &str = "fparkan-script-inspect-v2"; - -#[derive(Serialize)] -struct ArchiveInspectOutput<'a> { - schema_version: &'static str, - path: &'a str, - kind: &'a str, - entries: usize, - #[serde(skip_serializing_if = "Option::is_none")] - lookup_order_valid: Option<bool>, -} - -#[derive(Serialize)] -struct PrototypeInspectOutput { - schema_version: &'static str, - key: String, - roots: usize, - node_count: usize, - edge_count: usize, - unit_component_records: Vec<UnitComponentInspectOutput>, - edges: Vec<PrototypeGraphEdgeInspectOutput>, - prototype_requests: usize, - resolved: usize, - unit_references: usize, - unit_components: usize, - direct_references: usize, - wear_requests: usize, - wear: usize, - materials: usize, - textures: usize, - lightmaps: usize, - is_success: bool, - failures: Vec<GraphFailureOutput>, -} - -#[derive(Serialize)] -struct MissionGraphOutput { - schema_version: &'static str, - mission: String, - objects: usize, - paths: usize, - clans: usize, - extras: usize, - roots: usize, - node_count: usize, - edge_count: usize, - direct_references: usize, - unit_references: usize, - unit_components: usize, - prototype_requests: usize, - wear_requests: usize, - wear: usize, - materials: usize, - textures: usize, - lightmaps: usize, - is_success: bool, - failures: usize, -} - -#[derive(Serialize)] -struct MissionInspectOutput { - schema_version: &'static str, - mission: String, - objects: Vec<MissionObjectInspectOutput>, -} - -#[derive(Serialize)] -struct MissionObjectInspectOutput { - index: usize, - resource: String, - position: [f32; 3], - orientation_raw: [f32; 3], - scale: [f32; 3], -} - -#[derive(Serialize)] -struct TerrainInspectOutput { - schema_version: &'static str, - path: String, - positions: usize, - min: [f32; 3], - max: [f32; 3], - faces: usize, - slots: usize, - material_tags: Vec<TerrainMaterialTagCount>, - shade_pairs: usize, - #[serde(skip_serializing_if = "Option::is_none")] - shade_lookup_key_min: Option<u16>, - #[serde(skip_serializing_if = "Option::is_none")] - shade_lookup_key_max: Option<u16>, - shade_batch_boundaries: usize, -} - -#[derive(Serialize)] -struct TerrainMaterialTagCount { - tag: u16, - faces: usize, -} - -#[derive(Serialize)] -struct WearInspectOutput { - schema_version: &'static str, - archive: String, - resource: String, - materials: usize, - lightmaps: usize, - #[serde(skip_serializing_if = "Option::is_none")] - first_material: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - last_material: Option<String>, -} - -#[derive(Serialize)] -struct ModelInspectOutput<'a> { - schema_version: &'static str, - archive: &'a str, - resource: &'a str, - streams: usize, - nodes: usize, - node_stride: usize, - slots: usize, - positions: usize, - indices: usize, - batches: usize, - #[serde(skip_serializing_if = "Option::is_none")] - animation_keys: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - animation_frame_count: Option<u32>, - node38: Vec<ModelNodeInspectOutput>, -} - -#[derive(Serialize)] -struct ModelNodeInspectOutput { - index: usize, - parent_or_link_raw: u16, - anim_map_start: u16, - fallback_key: u16, - has_lod0_group0: bool, -} - -#[derive(Serialize)] -struct GraphFailureOutput { - root_index: usize, - edge: &'static str, - requiredness: &'static str, - message: String, - #[serde(skip_serializing_if = "Option::is_none")] - archive: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - resource: Option<String>, -} - -#[derive(Serialize)] -struct UnitComponentInspectOutput { - root_index: usize, - component_index: usize, - archive_raw_hex: String, - resource_raw_hex: String, - kind: u32, - parent_or_link: i32, - description_raw_hex: String, - tail0: u32, - tail1: u32, -} - -#[derive(Serialize)] -struct PrototypeGraphEdgeInspectOutput { - id: u32, - from: u32, - to: u32, - kind: &'static str, - requiredness: &'static str, - root_index: Option<usize>, - parent_edge: Option<u32>, - unit_component_index: Option<usize>, - archive: Option<String>, - resource_raw_hex: Option<String>, -} - -#[derive(Serialize)] -struct ScriptInspectOutput<'a> { - schema_version: &'static str, - path: &'a str, - opcode_handler_count: u32, - events: usize, - instructions: usize, - references: usize, - trailing_bytes: usize, - first_header_word_candidates: Vec<ScriptHeaderWordCount>, -} - -#[derive(Serialize)] -struct ScriptHeaderWordCount { - value: u32, - instructions: usize, -} - -#[derive(Serialize)] -struct VarSetInspectOutput<'a> { - schema_version: &'static str, - path: &'a str, - declarations: usize, - float_defaults: usize, - dword_defaults: usize, - first_name: Option<&'a str>, - last_name: Option<&'a str>, -} - -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).map_err(|e| e.to_string())?; - 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).map_err(|e| e.to_string())?; - if report.casefold_collisions > 0 { - return Err("casefold collisions found".to_string()); - } - if report.failures > 0 { - return Err(format!("corpus report found {} failures", report.failures)); - } - 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) - } - [domain, command, rest @ ..] if domain == "mission" && command == "inspect" => { - let rest = strip_format_json(rest)?; - inspect_mission(&rest) - } - [domain, command, rest @ ..] if domain == "terrain" && command == "inspect" => { - let rest = strip_format_json(rest)?; - inspect_terrain(&rest) - } - [domain, command, rest @ ..] if domain == "wear" && command == "inspect" => { - let rest = strip_format_json(rest)?; - inspect_wear(&rest) - } - [domain, command, rest @ ..] if domain == "model" && command == "inspect" => { - let rest = strip_format_json(rest)?; - inspect_model(&rest) - } - [domain, command, rest @ ..] if domain == "script" && command == "inspect" => { - let rest = strip_format_json(rest)?; - inspect_script(&rest) - } - [domain, command, rest @ ..] if domain == "varset" && command == "inspect" => { - let rest = strip_format_json(rest)?; - inspect_varset(&rest) - } - _ => Err(usage()), - } -} - -fn inspect_script(args: &[String]) -> Result<(), String> { - let path = parse_file_path(args, "script inspect")?; - let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?; - let package = - fparkan_script::decode(&bytes).map_err(|err| format!("{}: {err}", path.display()))?; - let display_path = path.display().to_string(); - println!("{}", script_inspect_json(&display_path, &package)?); - Ok(()) -} - -fn script_inspect_json( - path: &str, - package: &fparkan_script::ScriptPackage, -) -> Result<String, String> { - let instructions = package - .events - .iter() - .map(|event| event.instructions.len()) - .sum(); - let references = package - .events - .iter() - .flat_map(|event| &event.instructions) - .map(|instruction| instruction.references.len()) - .sum(); - let mut candidates = std::collections::BTreeMap::<u32, usize>::new(); - for instruction in package.events.iter().flat_map(|event| &event.instructions) { - *candidates.entry(instruction.header_words[0]).or_insert(0) += 1; - } - serialize_json(&ScriptInspectOutput { - schema_version: SCRIPT_INSPECT_SCHEMA, - path, - opcode_handler_count: package.opcode_handler_count, - events: package.events.len(), - instructions, - references, - trailing_bytes: package.trailing_bytes.len(), - first_header_word_candidates: candidates - .into_iter() - .map(|(value, instructions)| ScriptHeaderWordCount { - value, - instructions, - }) - .collect(), - }) -} - -fn inspect_varset(args: &[String]) -> Result<(), String> { - let path = parse_file_path(args, "varset inspect")?; - let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?; - let varset = - fparkan_script::parse_varset(&bytes).map_err(|err| format!("{}: {err}", path.display()))?; - let display_path = path.display().to_string(); - println!("{}", varset_inspect_json(&display_path, &varset)?); - Ok(()) -} - -fn varset_inspect_json(path: &str, varset: &fparkan_script::VarSet) -> Result<String, String> { - let float_defaults = varset - .declarations - .iter() - .filter(|declaration| declaration.type_name == fparkan_script::VarSetType::Float) - .count(); - let dword_defaults = varset.declarations.len() - float_defaults; - serialize_json(&VarSetInspectOutput { - schema_version: "fparkan-varset-inspect-v1", - path, - declarations: varset.declarations.len(), - float_defaults, - dword_defaults, - first_name: varset - .declarations - .first() - .map(|declaration| declaration.name.as_str()), - last_name: varset - .declarations - .last() - .map(|declaration| declaration.name.as_str()), - }) -} - -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 (mut graph, resolved, mut report) = - build_prototype_graph_report(&repository, vfs.as_ref(), &roots); - extend_graph_report_with_visual_dependencies(&repository, &mut report, &mut graph, &resolved); - println!( - "{}", - prototype_inspect_json(&key, &graph, &report).map_err(|err| err.to_string())? - ); - Ok(()) -} - -fn prototype_inspect_json( - key: &str, - graph: &fparkan_prototype::PrototypeGraph, - report: &fparkan_prototype::PrototypeGraphReport, -) -> Result<String, serde_json::Error> { - serde_json::to_string(&PrototypeInspectOutput { - schema_version: PROTOTYPE_INSPECT_SCHEMA, - key: key.to_string(), - roots: report.root_count, - node_count: graph.nodes.len(), - edge_count: graph.edges.len(), - unit_component_records: graph - .root_unit_components - .iter() - .enumerate() - .flat_map(|(root_index, records)| { - records - .iter() - .enumerate() - .map( - move |(component_index, record)| UnitComponentInspectOutput { - root_index, - component_index, - archive_raw_hex: hex_bytes(&record.archive_raw), - resource_raw_hex: hex_bytes(&record.resource_raw), - kind: record.kind, - parent_or_link: record.parent_or_link, - description_raw_hex: hex_bytes(&record.description_raw), - tail0: record.tail0, - tail1: record.tail1, - }, - ) - }) - .collect(), - edges: graph - .edges - .iter() - .map(|edge| PrototypeGraphEdgeInspectOutput { - id: edge.id.0, - from: edge.from.0, - to: edge.to.0, - kind: prototype_graph_edge_kind_label(edge.kind), - requiredness: prototype_graph_requiredness_label(edge.requiredness), - root_index: edge.provenance.as_ref().map(|value| value.root_index), - parent_edge: edge - .provenance - .as_ref() - .and_then(|value| value.parent_edge.map(|parent| parent.0)), - unit_component_index: edge - .provenance - .as_ref() - .and_then(|value| value.unit_component_index), - archive: edge - .provenance - .as_ref() - .and_then(|value| value.archive.clone()), - resource_raw_hex: edge - .provenance - .as_ref() - .and_then(|value| value.resource.as_ref()) - .map(|raw| hex_bytes(raw)), - }) - .collect(), - prototype_requests: graph.prototype_requests.len(), - resolved: report.resolved_count, - unit_references: report.unit_reference_count, - unit_components: report.unit_component_count, - direct_references: report.direct_reference_count, - wear_requests: report.wear_request_count, - wear: report.wear_resolved_count, - materials: report.material_resolved_count, - textures: report.texture_resolved_count, - lightmaps: report.lightmap_resolved_count, - is_success: report.is_success(), - failures: report - .failures - .iter() - .take(16) - .map(graph_failure_output) - .collect(), - }) -} - -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!( - "{}", - serialize_json(&MissionGraphOutput { - schema_version: MISSION_GRAPH_SCHEMA, - mission: mission.clone(), - objects: loaded.object_count, - paths: loaded.path_count, - clans: loaded.clan_count, - extras: loaded.extra_count, - roots: loaded.graph_root_count, - node_count: loaded.graph_node_count, - edge_count: loaded.graph_edge_count, - direct_references: loaded.graph_direct_reference_count, - unit_references: loaded.graph_unit_reference_count, - unit_components: loaded.graph_unit_component_count, - prototype_requests: loaded.graph_resolved_count, - wear_requests: loaded.graph_wear_request_count, - wear: loaded.graph_wear_resolved_count, - materials: loaded.graph_material_resolved_count, - textures: loaded.graph_texture_resolved_count, - lightmaps: loaded.graph_lightmap_resolved_count, - is_success: loaded.graph_failure_count == 0, - failures: loaded.graph_failure_count, - })? - ); - Ok(()) -} - -fn inspect_mission(args: &[String]) -> Result<(), String> { - let root = parse_root_alias(args)?; - let mission = parse_required(args, &["--mission"], "--mission")?; - let mission_path = normalize_relative(mission.as_bytes(), PathPolicy::StrictLegacy) - .map_err(|err| err.to_string())?; - let vfs = DirectoryVfs::new(root); - let bytes = vfs.read(&mission_path).map_err(|err| err.to_string())?; - let document = - decode_mission_payload(bytes, TmaProfile::Strict).map_err(|err| err.to_string())?; - let objects = document - .objects - .iter() - .enumerate() - .map(|(index, object)| MissionObjectInspectOutput { - index, - resource: String::from_utf8_lossy(&object.resource_name.raw).into_owned(), - position: object.position, - orientation_raw: object.orientation, - scale: object.scale, - }) - .collect(); - println!( - "{}", - serialize_json(&MissionInspectOutput { - schema_version: MISSION_INSPECT_SCHEMA, - mission, - objects, - })? - ); - Ok(()) -} - -fn inspect_archive(args: &[String]) -> Result<(), String> { - let path = parse_archive_path(args)?; - let inspection = inspect_archive_file(&path, 0).map_err(|err| err.clone())?; - - match inspection { - ArchiveInspection::Nres { - entries, - lookup_order_valid, - .. - } => { - println!( - "{}", - archive_inspect_json( - &path.display().to_string(), - "NRes", - entries, - Some(lookup_order_valid), - )? - ); - Ok(()) - } - ArchiveInspection::Rsli { entries } => { - println!( - "{}", - archive_inspect_json(&path.display().to_string(), "RsLi", entries, None)? - ); - Ok(()) - } - ArchiveInspection::Unsupported => { - Err(format!("{}: unsupported archive magic", path.display())) - } - } -} - -fn inspect_terrain(args: &[String]) -> Result<(), String> { - let path = parse_file_path(args, "terrain inspect")?; - let bounds = inspect_land_msh_bounds_file(&path)?; - let terrain = load_land_msh_from_path(&path)?; - let mut material_tags = terrain - .faces - .iter() - .fold(std::collections::BTreeMap::new(), |mut counts, face| { - *counts.entry(face.material_tag).or_insert(0usize) += 1; - counts - }) - .into_iter() - .map(|(tag, faces)| TerrainMaterialTagCount { tag, faces }) - .collect::<Vec<_>>(); - material_tags.sort_unstable_by_key(|entry| entry.tag); - let shade_pairs = (0..terrain.slots.slots_raw.len()) - .filter_map(|slot_index| terrain.slot_material_pairs(slot_index)) - .flatten() - .collect::<Vec<_>>(); - let shade_lookup_key_min = shade_pairs.iter().map(|pair| pair.shade_lookup_key()).min(); - let shade_lookup_key_max = shade_pairs.iter().map(|pair| pair.shade_lookup_key()).max(); - let shade_batch_boundaries = shade_pairs - .iter() - .filter(|pair| pair.flags & 0x0010 != 0) - .count(); - println!( - "{}", - serialize_json(&TerrainInspectOutput { - schema_version: TERRAIN_INSPECT_SCHEMA, - path: path.display().to_string(), - positions: bounds.positions, - min: bounds.min, - max: bounds.max, - faces: terrain.faces.len(), - slots: terrain.slots.slots_raw.len(), - material_tags, - shade_pairs: shade_pairs.len(), - shade_lookup_key_min, - shade_lookup_key_max, - shade_batch_boundaries, - })? - ); - Ok(()) -} - -fn inspect_wear(args: &[String]) -> Result<(), String> { - let root = parse_root_alias(args)?; - let archive = parse_required(args, &["--archive"], "--archive")?; - let resource = parse_required(args, &["--resource"], "--resource")?; - let inspection = inspect_wear_from_root(&root, &archive, &resource)?; - println!( - "{}", - serialize_json(&WearInspectOutput { - schema_version: WEAR_INSPECT_SCHEMA, - archive, - resource, - materials: inspection.materials, - lightmaps: inspection.lightmaps, - first_material: inspection.first_material, - last_material: inspection.last_material, - })? - ); - Ok(()) -} - -fn inspect_model(args: &[String]) -> Result<(), String> { - let root = parse_root_alias(args)?; - let archive = parse_required(args, &["--archive"], "--archive")?; - let resource = parse_required(args, &["--resource"], "--resource")?; - let inspection = inspect_model_from_root(&root, &archive, &resource)?; - println!("{}", model_inspect_json(&archive, &resource, &inspection)?); - Ok(()) -} - -fn model_inspect_json( - archive: &str, - resource: &str, - inspection: &ModelInspection, -) -> Result<String, String> { - serialize_json(&ModelInspectOutput { - schema_version: MODEL_INSPECT_SCHEMA, - archive, - resource, - streams: inspection.streams, - nodes: inspection.nodes, - node_stride: inspection.node_stride, - slots: inspection.slots, - positions: inspection.positions, - indices: inspection.indices, - batches: inspection.batches, - animation_keys: inspection.animation_keys, - animation_frame_count: inspection.animation_frame_count, - node38: inspection - .node38 - .iter() - .map(|node| ModelNodeInspectOutput { - index: node.index, - parent_or_link_raw: node.parent_or_link_raw, - anim_map_start: node.anim_map_start, - fallback_key: node.fallback_key, - has_lod0_group0: node.has_lod0_group0, - }) - .collect(), - }) -} - -fn archive_inspect_json( - path: &str, - kind: &str, - entries: usize, - lookup_order_valid: Option<bool>, -) -> Result<String, String> { - serialize_json(&ArchiveInspectOutput { - schema_version: ARCHIVE_INSPECT_SCHEMA, - path, - kind, - entries, - lookup_order_valid, - }) -} - -fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> { - parse_file_path(args, "archive inspect") -} - -fn parse_file_path(args: &[String], command: &str) -> Result<PathBuf, String> { - match args { - [path] => Ok(PathBuf::from(path)), - [flag, path] if flag == "--file" => Ok(PathBuf::from(path)), - _ => Err(format!("{command} requires <file> or --file <file>")), - } -} - -fn serialize_json<T: Serialize>(value: &T) -> Result<String, String> { - serde_json::to_string(value).map_err(|err| err.to_string()) -} - -fn graph_failure_output(failure: &fparkan_prototype::PrototypeGraphFailure) -> GraphFailureOutput { - GraphFailureOutput { - root_index: failure.root_index, - edge: prototype_graph_edge_label(failure.edge), - requiredness: prototype_graph_requiredness_label(failure.requiredness), - message: failure.message.clone(), - archive: failure - .provenance - .as_ref() - .and_then(|provenance| provenance.archive.clone()), - resource: failure.provenance.as_ref().and_then(|provenance| { - provenance - .resource - .as_ref() - .map(|raw| String::from_utf8_lossy(raw).into_owned()) - }), - } -} - -fn hex_bytes(raw: &[u8]) -> String { - let mut output = String::with_capacity(raw.len().saturating_mul(2)); - for byte in raw { - #[allow(clippy::expect_used)] - write!(&mut output, "{byte:02x}").expect("writing into String cannot fail"); - } - output -} - -fn prototype_graph_edge_kind_label( - edge: fparkan_prototype::PrototypeGraphEdgeKind, -) -> &'static str { - match edge { - fparkan_prototype::PrototypeGraphEdgeKind::MissionToRoot => "mission_to_root", - fparkan_prototype::PrototypeGraphEdgeKind::UnitDatToComponent => "unit_dat_to_component", - fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh => "prototype_to_mesh", - fparkan_prototype::PrototypeGraphEdgeKind::MeshToWear => "mesh_to_wear", - fparkan_prototype::PrototypeGraphEdgeKind::WearToMaterial => "wear_to_material", - fparkan_prototype::PrototypeGraphEdgeKind::MaterialToTexture => "material_to_texture", - fparkan_prototype::PrototypeGraphEdgeKind::WearToLightmap => "wear_to_lightmap", - } -} - -fn prototype_graph_edge_label(edge: fparkan_prototype::PrototypeGraphEdge) -> &'static str { - match edge { - fparkan_prototype::PrototypeGraphEdge::MissionToUnitDat => "mission_to_unit_dat", - fparkan_prototype::PrototypeGraphEdge::MissionToObjectsRegistry => { - "mission_to_objects_registry" - } - fparkan_prototype::PrototypeGraphEdge::UnitDatToComponent => "unit_dat_to_component", - fparkan_prototype::PrototypeGraphEdge::PrototypeToMesh => "prototype_to_mesh", - fparkan_prototype::PrototypeGraphEdge::MeshToWear => "mesh_to_wear", - fparkan_prototype::PrototypeGraphEdge::WearToMaterial => "wear_to_material", - fparkan_prototype::PrototypeGraphEdge::MaterialToTexture => "material_to_texture", - fparkan_prototype::PrototypeGraphEdge::WearToLightmap => "wear_to_lightmap", - } -} - -fn prototype_graph_requiredness_label( - requiredness: fparkan_prototype::PrototypeGraphRequiredness, -) -> &'static str { - match requiredness { - fparkan_prototype::PrototypeGraphRequiredness::Required => "required", - fparkan_prototype::PrototypeGraphRequiredness::Optional => "optional", - fparkan_prototype::PrototypeGraphRequiredness::Fallback => "fallback", - } -} - -fn usage() -> String { - "usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | terrain inspect <Land.msh> [--format json] | wear inspect --root <path> --archive <archive> --resource <wear.wea> [--format json] | model inspect --root <path> --archive <archive> --resource <model.msh> [--format json] | script inspect <file> [--format json] | varset inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph|inspect --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 script_inspect_json_has_canonical_field_order() { - let package = - fparkan_script::decode(&[73, 0, 0, 0, 0, 0, 0, 0]).expect("minimal script package"); - assert_eq!( - script_inspect_json("script.scr", &package), - Ok("{\"schema_version\":\"fparkan-script-inspect-v2\",\"path\":\"script.scr\",\"opcode_handler_count\":73,\"events\":0,\"instructions\":0,\"references\":0,\"trailing_bytes\":0,\"first_header_word_candidates\":[]}".to_string()) - ); - } - - #[test] - fn varset_inspect_json_has_canonical_field_order() { - let varset = - fparkan_script::parse_varset(b"VAR( float, f0, 0)\nVAR( DWORD, d0, 0xffffffff)\n") - .expect("minimal varset"); - assert_eq!( - varset_inspect_json("varset.var", &varset), - Ok("{\"schema_version\":\"fparkan-varset-inspect-v1\",\"path\":\"varset.var\",\"declarations\":2,\"float_defaults\":1,\"dword_defaults\":1,\"first_name\":\"f0\",\"last_name\":\"d0\"}".to_string()) - ); - } - - #[test] - fn archive_json_has_schema_version() { - let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true)) - .expect("serialize archive inspection"); - - 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).expect("serialize"); - - assert_eq!( - json, - "{\"schema_version\":\"fparkan-prototype-inspect-v2\",\"key\":\"root\",\"roots\":1,\"node_count\":0,\"edge_count\":0,\"unit_component_records\":[],\"edges\":[],\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear_requests\":0,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"is_success\":true,\"failures\":[]}" - ); - } - - #[test] - fn mission_graph_json_has_canonical_field_order() { - let json = serialize_json(&MissionGraphOutput { - schema_version: MISSION_GRAPH_SCHEMA, - mission: "MISSIONS/Autodemo.00/data.tma".to_string(), - objects: 2, - paths: 3, - clans: 4, - extras: 5, - roots: 6, - node_count: 7, - edge_count: 8, - direct_references: 9, - unit_references: 10, - unit_components: 11, - prototype_requests: 12, - wear_requests: 13, - wear: 14, - materials: 15, - textures: 16, - lightmaps: 17, - is_success: true, - failures: 0, - }) - .expect("serialize"); - - assert_eq!( - json, - "{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":\"MISSIONS/Autodemo.00/data.tma\",\"objects\":2,\"paths\":3,\"clans\":4,\"extras\":5,\"roots\":6,\"node_count\":7,\"edge_count\":8,\"direct_references\":9,\"unit_references\":10,\"unit_components\":11,\"prototype_requests\":12,\"wear_requests\":13,\"wear\":14,\"materials\":15,\"textures\":16,\"lightmaps\":17,\"is_success\":true,\"failures\":0}" - ); - } - - #[test] - fn mission_inspect_output_retains_raw_transform_fields() { - let json = serialize_json(&MissionInspectOutput { - schema_version: MISSION_INSPECT_SCHEMA, - mission: "MISSIONS/test/data.tma".to_string(), - objects: vec![MissionObjectInspectOutput { - index: 1, - resource: "unit.dat".to_string(), - position: [1.0, 2.0, 3.0], - orientation_raw: [4.0, 5.0, 6.0], - scale: [7.0, 8.0, 9.0], - }], - }) - .expect("serialize mission inspection"); - - assert_eq!( - json, - "{\"schema_version\":\"fparkan-mission-inspect-v1\",\"mission\":\"MISSIONS/test/data.tma\",\"objects\":[{\"index\":1,\"resource\":\"unit.dat\",\"position\":[1.0,2.0,3.0],\"orientation_raw\":[4.0,5.0,6.0],\"scale\":[7.0,8.0,9.0]}]}" - ); - } - - #[test] - fn terrain_inspect_output_retains_axis_bounds() { - let json = serialize_json(&TerrainInspectOutput { - schema_version: TERRAIN_INSPECT_SCHEMA, - path: "DATA/MAPS/AutoMAP/Land.msh".to_string(), - positions: 3, - min: [-1.0, -2.0, -3.0], - max: [4.0, 5.0, 6.0], - faces: 2, - slots: 4, - material_tags: vec![ - TerrainMaterialTagCount { tag: 0, faces: 1 }, - TerrainMaterialTagCount { tag: 3, faces: 1 }, - ], - shade_pairs: 2, - shade_lookup_key_min: Some(1), - shade_lookup_key_max: Some(2), - shade_batch_boundaries: 1, - }) - .expect("serialize terrain inspection"); - - assert_eq!( - json, - "{\"schema_version\":\"fparkan-terrain-inspect-v1\",\"path\":\"DATA/MAPS/AutoMAP/Land.msh\",\"positions\":3,\"min\":[-1.0,-2.0,-3.0],\"max\":[4.0,5.0,6.0],\"faces\":2,\"slots\":4,\"material_tags\":[{\"tag\":0,\"faces\":1},{\"tag\":3,\"faces\":1}],\"shade_pairs\":2,\"shade_lookup_key_min\":1,\"shade_lookup_key_max\":2,\"shade_batch_boundaries\":1}" - ); - } - - #[test] - fn wear_inspect_output_retains_material_bounds() { - let json = serialize_json(&WearInspectOutput { - schema_version: WEAR_INSPECT_SCHEMA, - archive: "system.rlb".to_string(), - resource: "SHADE.WEA".to_string(), - materials: 1, - lightmaps: 0, - first_material: Some("LIGHT1".to_string()), - last_material: Some("LIGHT1".to_string()), - }) - .expect("serialize wear inspection"); - - assert_eq!( - json, - "{\"schema_version\":\"fparkan-wear-inspect-v1\",\"archive\":\"system.rlb\",\"resource\":\"SHADE.WEA\",\"materials\":1,\"lightmaps\":0,\"first_material\":\"LIGHT1\",\"last_material\":\"LIGHT1\"}" - ); - } -} diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml index 49e2cad..780a21c 100644 --- a/apps/fparkan-game/Cargo.toml +++ b/apps/fparkan-game/Cargo.toml @@ -6,20 +6,14 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" } -fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" } -fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" } -fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" } -fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" } -fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" } -fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" } -fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" } -fparkan-terrain = { path = "../../crates/fparkan-terrain", version = "0.1.0" } -fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" } -fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" } +fparkan-assets = { path = "../../crates/fparkan-assets" } +fparkan-path = { path = "../../crates/fparkan-path" } +fparkan-render = { path = "../../crates/fparkan-render" } +fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" } +fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } +fparkan-runtime = { path = "../../crates/fparkan-runtime" } +fparkan-terrain = { path = "../../crates/fparkan-terrain" } +fparkan-vfs = { path = "../../crates/fparkan-vfs" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" winit = { version = "0.30", default-features = false, features = ["rwh_06"] } - -[lints] -workspace = true diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index af7fe1d..2130bf5 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -1,60 +1,32 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] -#![allow(clippy::print_stderr, clippy::print_stdout)] + //! `FParkan` render-planning composition root. -use fparkan_assets::{PreparedTextureUsage, PreparedVisual}; -use fparkan_inspection::load_standalone_wear_material_texture_mip0_rgba8_from_root; +use fparkan_assets::{ + load_standalone_wear_material_texture_mip0_rgba8_from_root, PreparedTextureUsage, + PreparedVisual, +}; use fparkan_path::ResourceName; -use fparkan_platform::WindowPort; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; -use fparkan_render::{ - build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, - LegacyD3d7Projection, LegacyIron3dEulerTransform, RawCameraTransform, RenderBackend, - RenderCommand, RenderCommandList, RenderPhase, RenderProfile, RenderSnapshot, - RenderSnapshotDraw, -}; +use fparkan_render::{LegacyD3d7Projection, LegacyIron3dEulerTransform, RawCameraTransform}; use fparkan_render_vulkan::{ project_land_msh_to_static_mesh_in_legacy_world_space, project_land_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh_in_world_space_with_node_fallback_poses, project_msh_to_static_mesh_in_world_space_with_node_sampled_poses, project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, - project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanPlanningBackend, - VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, - VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, - VulkanStaticXyFrame, + project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanSmokeFrameOutcome, + VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticMaterial, + VulkanStaticMesh, VulkanStaticTexture, VulkanStaticXyFrame, }; use fparkan_runtime::{ - create, frame, load_mission, load_mission_static_preview, load_mission_static_preview_roots, - load_mission_static_preview_roots_with_progress, load_mission_with_progress, - loaded_mission_assets, loaded_mission_object_drafts, loaded_terrain, EngineConfig, EngineMode, - EngineServices, MissionAssets, MissionLoadPhase, MissionObjectDraft, MissionRequest, + create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets, + loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, + MissionObjectDraft, MissionRequest, }; use fparkan_terrain::TerrainMaterialLayers; use fparkan_terrain::TerrainWorld; use fparkan_vfs::DirectoryVfs; -#[cfg(test)] -use fparkan_world::OriginalObjectId; -use fparkan_world::{TransformState, WorldSnapshot}; use serde::Deserialize; use std::num::NonZeroUsize; use std::path::PathBuf; @@ -83,91 +55,42 @@ fn main() { 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 mut engine = create(services).map_err(|err| err.to_string())?; let loaded = load_requested_mission(&mut engine, &args)?; - if args.backend == RenderBackendMode::StaticVulkan { - let mission_assets = loaded_mission_assets(&engine) - .ok_or_else(|| "mission assets are unavailable after loading".to_string())?; - let terrain = loaded_terrain(&engine) - .ok_or_else(|| "mission terrain is unavailable after loading".to_string())?; - let roots = loaded_mission_object_drafts(&engine) - .map(|drafts| &drafts[..args.preview_roots.get().min(drafts.len())]) - .filter(|roots| !roots.is_empty()) - .ok_or_else(|| { - "selected mission object drafts are unavailable after loading".to_string() - })?; - let camera = args - .legacy_camera_capture - .as_deref() - .map(load_legacy_camera_capture) - .transpose()?; - let preview = static_preview_mesh_and_materials( - mission_assets, - terrain, - roots, - camera, - args.static_animation_frame, - args.static_material_phase, - &args.root, - &loaded.land_msh_path, - )?; - return run_static_vulkan_mode( - preview, - roots.len(), - args.frames, - &args.mission, - loaded.object_count, - args.readback_out.as_deref(), - ); - } - - let mut backend = VulkanPlanningBackend::new(); - let _request = WinitWindow::default_render_request(); - let window = WinitWindow::synthetic(1280, 720); - let _ = window.drawable_size(); - let _ = window.handle(); - 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 mission_assets = loaded_mission_assets(&engine); - let mission_drafts = fparkan_runtime::loaded_mission_object_drafts(&engine); - let commands = - render_snapshot_commands_with_assets(&result.snapshot, mission_assets, mission_drafts) - .map_err(|err| format!("render snapshot: {err}"))?; - last_draw_count = commands - .commands - .iter() - .filter(|command| matches!(command, RenderCommand::Draw(_))) - .count(); - backend - .execute(&commands) - .map_err(|err| format!("render backend: {err}"))?; - } - - let capture_report = backend.report(); - - Ok(format!( - "{{\"report_kind\":\"render-planning\",\"backend\":\"vulkan-planning\",\"window\":\"synthetic\",\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"submission_plans\":{},\"last_command_capture_bytes\":{},\"hash\":{}}}", - json_string(&args.mission), - loaded.object_count, + let mission_assets = loaded_mission_assets(&engine) + .ok_or_else(|| "mission assets are unavailable after loading".to_string())?; + let terrain = loaded_terrain(&engine) + .ok_or_else(|| "mission terrain is unavailable after loading".to_string())?; + let roots = loaded_mission_object_drafts(&engine) + .map(|drafts| &drafts[..args.preview_roots.get().min(drafts.len())]) + .filter(|roots| !roots.is_empty()) + .ok_or_else(|| { + "selected mission object drafts are unavailable after loading".to_string() + })?; + let camera = args + .legacy_camera_capture + .as_deref() + .map(load_legacy_camera_capture) + .transpose()?; + let preview = static_preview_mesh_and_materials( + mission_assets, + terrain, + roots, + camera, + args.static_animation_frame, + args.static_material_phase, + &args.root, + &loaded.land_msh_path, + )?; + run_static_vulkan_mode( + preview, args.frames, - last_tick, - last_draw_count, - capture_report.execution.submission_plans, - capture_report.execution.last_capture_size, - json_hash(&last_hash) - )) + &args.mission, + loaded.object_count, + args.readback_out.as_deref(), + args.validation, + ) } fn load_requested_mission( @@ -177,62 +100,10 @@ fn load_requested_mission( let request = MissionRequest { key: args.mission.clone(), }; - if let Some(progress_path) = args.load_progress.as_ref() { - prepare_load_progress_path(progress_path)?; - let progress_started = std::time::Instant::now(); - let mut write_error = None; - let loaded = if args.backend == RenderBackendMode::StaticVulkan { - load_mission_static_preview_roots_with_progress( - engine, - request, - args.preview_roots, - |phase| { - if write_error.is_none() { - if let Err(err) = - write_load_progress(progress_path, progress_started.elapsed(), phase) - { - write_error = Some(err); - } - } - }, - ) - } else { - load_mission_with_progress(engine, request, |phase| { - if write_error.is_none() { - if let Err(err) = - write_load_progress(progress_path, progress_started.elapsed(), phase) - { - write_error = Some(err); - } - } - }) - } - .map_err(|err| err.to_string())?; - if let Some(err) = write_error { - return Err(err); - } - std::fs::OpenOptions::new() - .append(true) - .open(progress_path) - .and_then(|mut file| { - use std::io::Write; - writeln!( - file, - "Complete\telapsed_ms={}", - progress_started.elapsed().as_millis() - ) - }) - .map_err(|err| format!("{}: {err}", progress_path.display()))?; - return Ok(loaded); - } - if args.backend == RenderBackendMode::StaticVulkan { - if args.preview_roots.get() == 1 { - load_mission_static_preview(engine, request) - } else { - load_mission_static_preview_roots(engine, request, args.preview_roots) - } + if args.preview_roots.get() == 1 { + load_mission_static_preview(engine, request) } else { - load_mission(engine, request) + load_mission_static_preview_roots(engine, request, args.preview_roots) } .map_err(|err| err.to_string()) } @@ -243,11 +114,8 @@ struct StaticPreviewScene { materials: Vec<VulkanStaticMaterial>, camera: VulkanStaticCamera, camera_mode: &'static str, - clip_visible_vertices: usize, mesh_components: usize, terrain_components: usize, - animation_frame: Option<u16>, - material_phase: Option<u16>, } /// Projects the mission terrain plus every MSH component of the explicitly @@ -259,7 +127,7 @@ struct StaticPreviewScene { /// retained on draw ranges but awaits a recovered blend equation; orientation, /// later material phases, animation, lightmaps and gameplay visibility remain /// outside this bridge. -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] + fn static_preview_mesh_and_materials( assets: &MissionAssets, terrain: &TerrainWorld, @@ -366,7 +234,6 @@ fn static_preview_mesh_and_materials( } let camera = legacy_camera.unwrap_or_default(); Ok(StaticPreviewScene { - clip_visible_vertices: static_preview_clip_visible_vertices(&mesh, camera), mesh, materials, camera, @@ -377,51 +244,9 @@ fn static_preview_mesh_and_materials( }, mesh_components, terrain_components: 1, - animation_frame: static_animation_frame, - material_phase: static_material_phase, }) } -/// Counts source vertices inside the Vulkan clip volume for a static preview. -/// -/// The static vertex shader receives row-major D3D7 matrix data as a GLSL -/// column-major `mat4`; its multiplication is consequently equivalent to this -/// row-vector calculation. This remains CPU-only diagnostic evidence and does -/// not change the renderer's clipping rules. -fn static_preview_clip_visible_vertices( - mesh: &VulkanStaticMesh, - camera: VulkanStaticCamera, -) -> usize { - mesh.vertices - .iter() - .filter(|vertex| { - let [x, y, z] = vertex.position; - let matrix = camera.clip_from_world; - let clip_x = x.mul_add( - matrix[0], - y.mul_add(matrix[4], z.mul_add(matrix[8], matrix[12])), - ); - let clip_y = x.mul_add( - matrix[1], - y.mul_add(matrix[5], z.mul_add(matrix[9], matrix[13])), - ); - let clip_z = x.mul_add( - matrix[2], - y.mul_add(matrix[6], z.mul_add(matrix[10], matrix[14])), - ); - let clip_w = x.mul_add( - matrix[3], - y.mul_add(matrix[7], z.mul_add(matrix[11], matrix[15])), - ); - clip_w.is_finite() - && clip_w > 0.0 - && (-clip_w..=clip_w).contains(&clip_x) - && (-clip_w..=clip_w).contains(&clip_y) - && (0.0..=clip_w).contains(&clip_z) - }) - .count() -} - fn static_preview_xy_frame( assets: &MissionAssets, terrain: &TerrainWorld, @@ -518,7 +343,7 @@ fn static_preview_terrain_base_materials( .into_iter() .map(|packed_tag| { let selection = TerrainMaterialLayers::from_packed_tag(packed_tag).land2_selection(); - let selected = load_standalone_wear_material_texture_mip0_rgba8_from_root( + let image = load_standalone_wear_material_texture_mip0_rgba8_from_root( root, &wear_path, selection.material_index, @@ -535,9 +360,9 @@ fn static_preview_terrain_base_materials( materials.push(VulkanStaticMaterial { material_index: preview_selector, texture: VulkanStaticTexture { - width: selected.image.width, - height: selected.image.height, - rgba8: selected.image.rgba8, + width: image.width, + height: image.height, + rgba8: image.rgba8, }, }); Ok((packed_tag, preview_selector)) @@ -664,46 +489,23 @@ fn append_static_preview_component( Ok(()) } -fn prepare_load_progress_path(path: &std::path::Path) -> Result<(), String> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?; - } - std::fs::write(path, "Starting\telapsed_ms=0\n") - .map_err(|err| format!("{}: {err}", path.display())) -} - -fn write_load_progress( - path: &std::path::Path, - elapsed: std::time::Duration, - phase: MissionLoadPhase, -) -> Result<(), String> { - use std::io::Write; - - let mut file = std::fs::OpenOptions::new() - .append(true) - .open(path) - .map_err(|err| format!("{}: {err}", path.display()))?; - writeln!(file, "{phase:?}\telapsed_ms={}", elapsed.as_millis()) - .map_err(|err| format!("{}: {err}", path.display())) -} - fn run_static_vulkan_mode( preview: StaticPreviewScene, - preview_roots: usize, target_frames: u64, mission: &str, object_count: usize, readback_out: Option<&std::path::Path>, + enable_validation: bool, ) -> Result<String, String> { let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; event_loop.set_control_flow(ControlFlow::Poll); let mut app = StaticVulkanApp::new( preview, - preview_roots, target_frames, mission, object_count, readback_out.map(std::path::Path::to_path_buf), + enable_validation, ); if let Err(err) = event_loop.run_app(&mut app) { app.error = Some(format!("winit event loop: {err}")); @@ -716,16 +518,13 @@ struct StaticVulkanApp { materials: Vec<VulkanStaticMaterial>, camera: VulkanStaticCamera, camera_mode: &'static str, - clip_visible_vertices: usize, mesh_components: usize, terrain_components: usize, - animation_frame: Option<u16>, - material_phase: Option<u16>, - preview_roots: usize, target_frames: u64, mission: String, object_count: usize, readback_out: Option<PathBuf>, + enable_validation: bool, window_id: Option<WindowId>, window: Option<Window>, renderer: Option<VulkanSmokeRenderer>, @@ -737,27 +536,24 @@ struct StaticVulkanApp { impl StaticVulkanApp { fn new( preview: StaticPreviewScene, - preview_roots: usize, target_frames: u64, mission: &str, object_count: usize, readback_out: Option<PathBuf>, + enable_validation: bool, ) -> Self { Self { mesh: preview.mesh, materials: preview.materials, camera: preview.camera, camera_mode: preview.camera_mode, - clip_visible_vertices: preview.clip_visible_vertices, mesh_components: preview.mesh_components, terrain_components: preview.terrain_components, - animation_frame: preview.animation_frame, - material_phase: preview.material_phase, - preview_roots, target_frames, mission: mission.to_string(), object_count, readback_out, + enable_validation, window_id: None, window: None, renderer: None, @@ -788,7 +584,9 @@ impl StaticVulkanApp { } }; self.window.take(); - if report.validation.warning_count != 0 || report.validation.error_count != 0 { + if self.enable_validation + && (report.validation.warning_count != 0 || report.validation.error_count != 0) + { self.error = Some(format!( "native Vulkan validation must stay clean (warnings={}, errors={})", report.validation.warning_count, report.validation.error_count @@ -796,6 +594,11 @@ impl StaticVulkanApp { event_loop.exit(); return; } + let validation_status = if self.enable_validation { + "clean" + } else { + "disabled" + }; let readback_path = match (&self.readback_out, &report.readback_artifact) { (Some(path), Some(artifact)) => { if let Some(parent) = path.parent() { @@ -821,27 +624,16 @@ impl StaticVulkanApp { (None, _) => None, }; self.output = Some(format!( - "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"animation_frame\":{},\"material_phase\":{},\"mesh_components\":{},\"terrain_components\":{},\"clip_visible_vertices\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_format\":{},\"readback_bytes\":{},\"readback_hash\":{},\"readback_path\":{}}}", - json_string(self.camera_mode), - json_string(&self.mission), - self.object_count, + "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, materials={}, validation={}, readback={}", + self.mission, self.frames_presented, - self.preview_roots, - self.animation_frame.map_or_else(|| "null".to_string(), |frame| frame.to_string()), - self.material_phase.map_or_else(|| "null".to_string(), |phase| phase.to_string()), + self.object_count, self.mesh_components, self.terrain_components, - self.clip_visible_vertices, + self.camera_mode, self.materials.len(), - report.renderer_report.swapchain_extent.0, - report.renderer_report.swapchain_extent.1, - report.renderer_report.swapchain_image_count, - report.validation.warning_count, - report.validation.error_count, - report.readback_artifact.as_ref().map_or(0, |artifact| artifact.format), - report.renderer_report.readback_byte_count, - report.renderer_report.readback_fnv1a64, - readback_path.as_deref().map_or_else(|| "null".to_string(), json_string), + validation_status, + readback_path.as_deref().unwrap_or("none"), )); event_loop.exit(); } @@ -890,7 +682,7 @@ impl ApplicationHandler for StaticVulkanApp { native_handles, drawable_extent: (size.width.max(1), size.height.max(1)), render_request: WinitWindow::default_render_request(), - enable_validation: true, + enable_validation: self.enable_validation, mesh: self.mesh.clone(), camera: self.camera, materials: self.materials.clone(), @@ -920,8 +712,7 @@ impl ApplicationHandler for StaticVulkanApp { } match event { WindowEvent::CloseRequested => { - self.error = Some("native Vulkan window closed before completion".to_string()); - event_loop.exit(); + self.complete(event_loop); } WindowEvent::Resized(size) => { if let Some(renderer) = self.renderer.as_mut() { @@ -964,157 +755,12 @@ impl ApplicationHandler for StaticVulkanApp { } } -#[cfg(test)] -fn render_snapshot_commands(snapshot: &WorldSnapshot) -> Result<RenderCommandList, String> { - render_snapshot_commands_with_assets(snapshot, None, None) -} - -fn render_snapshot_commands_with_assets( - snapshot: &WorldSnapshot, - mission_assets: Option<&MissionAssets>, - mission_drafts: Option<&[MissionObjectDraft]>, -) -> Result<RenderCommandList, String> { - let render_snapshot = render_snapshot_with_assets(snapshot, mission_assets, mission_drafts); - build_commands(&render_snapshot, RenderProfile::default()).map_err(|err| err.to_string()) -} - -fn render_snapshot_with_assets( - snapshot: &WorldSnapshot, - mission_assets: Option<&MissionAssets>, - mission_drafts: Option<&[MissionObjectDraft]>, -) -> RenderSnapshot { - let mut draws = Vec::with_capacity(snapshot.objects.len()); - for (index, handle) in snapshot.objects.iter().enumerate() { - let visuals = mission_assets.map_or(&[][..], |assets| assets.visuals_for_object(index)); - let visual_count = visuals.len().max(1); - for visual_index in 0..visual_count { - let prepared = mission_assets.and_then(|assets| { - visuals - .get(visual_index) - .and_then(|visual_id| assets.visual_by_id(*visual_id)) - }); - let mesh = prepared.map_or_else( - || GpuMeshId(u64::from(handle.slot) + 1), - |visual| GpuMeshId(visual.id.raw()), - ); - let material = prepared - .and_then(PreparedVisual::primary_material_id) - .map_or(GpuMaterialId(1), |material_id| { - GpuMaterialId(material_id.raw()) - }); - let stable_order = if visual_count == 1 { - u64::from(handle.slot) - } else { - u64::from(handle.slot) - .wrapping_mul(1_000_003) - .wrapping_add(u64::try_from(visual_index).unwrap_or(u64::MAX)) - }; - let draw_id = snapshot - .tick - .0 - .wrapping_mul(1_000_003) - .wrapping_add(stable_order); - draws.push(RenderSnapshotDraw { - id: DrawId(draw_id), - phase: RenderPhase::Opaque, - object_id: mission_drafts - .and_then(|drafts| drafts.get(index)) - .and_then(|draft| draft.original_id), - mesh, - material_slots: vec![material], - material_index: 0, - pipeline_state: fparkan_render::LegacyPipelineState::default(), - transform: snapshot_transform(snapshot, *handle).unwrap_or_else(|| { - mission_drafts - .and_then(|drafts| drafts.get(index)) - .map_or_else( - || identity_transform(index_to_f32(index)), - mission_position_scale_transform, - ) - }), - range: IndexRange { start: 0, count: 3 }, - stable_order, - }); - } - } - RenderSnapshot { - camera: CameraSnapshot::default(), - draws, - } -} - -fn snapshot_transform( - snapshot: &WorldSnapshot, - handle: fparkan_world::ObjectHandle, -) -> Option<[f32; 16]> { - snapshot - .transforms - .iter() - .find_map(|(candidate, transform)| (*candidate == handle).then_some(*transform)) - .map(transform_state_position_scale_transform) -} - -fn transform_state_position_scale_transform(transform: TransformState) -> [f32; 16] { - let position = transform.position.map(f32::from_bits); - let scale = transform.scale.map(f32::from_bits); - [ - scale[0], - 0.0, - 0.0, - position[0], - 0.0, - scale[1], - 0.0, - position[1], - 0.0, - 0.0, - scale[2], - position[2], - 0.0, - 0.0, - 0.0, - 1.0, - ] -} - -fn mission_position_scale_transform(draft: &MissionObjectDraft) -> [f32; 16] { - [ - draft.scale[0], - 0.0, - 0.0, - draft.position[0], - 0.0, - draft.scale[1], - 0.0, - draft.position[1], - 0.0, - 0.0, - draft.scale[2], - draft.position[2], - 0.0, - 0.0, - 0.0, - 1.0, - ] -} - -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, - backend: RenderBackendMode, - load_progress: Option<PathBuf>, + validation: bool, readback_out: Option<PathBuf>, preview_roots: NonZeroUsize, legacy_camera_capture: Option<PathBuf>, @@ -1122,20 +768,12 @@ struct Args { static_material_phase: Option<u16>, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RenderBackendMode { - Planning, - StaticVulkan, -} - impl Args { - #[allow(clippy::too_many_lines)] fn parse(args: &[String]) -> Result<Self, String> { let mut root = None; let mut mission = None; let mut frames = 1; - let mut backend = RenderBackendMode::Planning; - let mut load_progress = None; + let mut validation = false; let mut readback_out = None; // A native static-Vulkan invocation is the usable mission preview, so // its default scope covers every root. `PreviewRoots` is clamped by the @@ -1169,23 +807,8 @@ impl Args { .parse() .map_err(|_| "--frames must be an integer".to_string())?; } - "--backend" => { - backend = match iter - .next() - .ok_or_else(|| "--backend requires a value".to_string())? - .as_str() - { - "planning" => RenderBackendMode::Planning, - "static-vulkan" => RenderBackendMode::StaticVulkan, - _ => return Err("--backend must be planning or static-vulkan".to_string()), - }; - } - "--load-progress" => { - load_progress = Some( - iter.next() - .map(PathBuf::from) - .ok_or_else(|| "--load-progress requires a path".to_string())?, - ); + "--validation" => { + validation = true; } "--readback-out" => { readback_out = Some( @@ -1235,24 +858,11 @@ impl Args { if frames == 0 { return Err("--frames must be greater than zero".to_string()); } - if legacy_camera_capture.is_some() && backend != RenderBackendMode::StaticVulkan { - return Err("--legacy-camera-capture requires --backend static-vulkan".to_string()); - } - if readback_out.is_some() && backend != RenderBackendMode::StaticVulkan { - return Err("--readback-out requires --backend static-vulkan".to_string()); - } - if static_animation_frame.is_some() && backend != RenderBackendMode::StaticVulkan { - return Err("--static-animation-frame requires --backend static-vulkan".to_string()); - } - if static_material_phase.is_some() && backend != RenderBackendMode::StaticVulkan { - return Err("--static-material-phase requires --backend static-vulkan".to_string()); - } Ok(Self { root, mission, frames, - backend, - load_progress, + validation, readback_out, preview_roots, legacy_camera_capture, @@ -1339,87 +949,19 @@ fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, 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() => { - use std::fmt::Write as _; - let _ = write!(out, "\\u{:04x}", c as u32); - } - 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>] [--backend <planning|static-vulkan>] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>] [--load-progress <path>]".to_string() + "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>]".to_string() } #[cfg(test)] mod tests { use super::*; - use fparkan_assets::AssetId; - 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 static_preview_clip_diagnostic_matches_vulkan_clip_bounds() { - let mesh = VulkanStaticMesh { - vertices: vec![ - fparkan_render_vulkan::VulkanStaticVertex { - position: [0.0, 0.0, 0.0], - color: [0.0; 3], - uv: [0.0; 2], - }, - fparkan_render_vulkan::VulkanStaticVertex { - position: [1.0, -1.0, 1.0], - color: [0.0; 3], - uv: [0.0; 2], - }, - fparkan_render_vulkan::VulkanStaticVertex { - position: [1.01, 0.0, 0.0], - color: [0.0; 3], - uv: [0.0; 2], - }, - fparkan_render_vulkan::VulkanStaticVertex { - position: [0.0, 0.0, -0.01], - color: [0.0; 3], - uv: [0.0; 2], - }, - ], - indices: Vec::new(), - draw_ranges: Vec::new(), - }; - assert_eq!( - static_preview_clip_visible_vertices(&mesh, VulkanStaticCamera::default()), - 2 - ); - } - - #[test] fn parses_required_args() { assert_eq!( Args::parse(&strings(&[ @@ -1434,8 +976,7 @@ mod tests { root: PathBuf::from("testdata/IS"), mission: "MISSIONS/Autodemo.00/data.tma".to_string(), frames: 3, - backend: RenderBackendMode::Planning, - load_progress: None, + validation: false, readback_out: None, preview_roots: NonZeroUsize::MAX, legacy_camera_capture: None, @@ -1446,29 +987,16 @@ mod tests { } #[test] - fn parses_static_vulkan_backend() { - assert_eq!( - Args::parse(&strings(&[ - "--root", - "testdata/IS", - "--mission", - "MISSIONS/Autodemo.00/data.tma", - "--backend", - "static-vulkan", - ])), - Ok(Args { - root: PathBuf::from("testdata/IS"), - mission: "MISSIONS/Autodemo.00/data.tma".to_string(), - frames: 1, - backend: RenderBackendMode::StaticVulkan, - load_progress: None, - readback_out: None, - preview_roots: NonZeroUsize::MAX, - legacy_camera_capture: None, - static_animation_frame: None, - static_material_phase: None, - }) - ); + fn parses_validation_flag() { + let parsed = Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--validation", + ])) + .expect("valid validation flag"); + assert!(parsed.validation); } #[test] @@ -1478,8 +1006,6 @@ mod tests { "testdata/IS", "--mission", "MISSIONS/Autodemo.00/data.tma", - "--backend", - "static-vulkan", "--preview-roots", "2", ])) @@ -1491,26 +1017,12 @@ mod tests { } #[test] - fn readback_output_requires_static_vulkan_and_is_retained() { - let common = [ - "--root", - "testdata/IS", - "--mission", - "MISSIONS/Autodemo.00/data.tma", - "--readback-out", - "target/frame.raw", - ]; - assert_eq!( - Args::parse(&strings(&common)), - Err("--readback-out requires --backend static-vulkan".to_string()) - ); + fn parses_readback_output() { let parsed = Args::parse(&strings(&[ "--root", "testdata/IS", "--mission", "MISSIONS/Autodemo.00/data.tma", - "--backend", - "static-vulkan", "--readback-out", "target/frame.raw", ])) @@ -1519,26 +1031,12 @@ mod tests { } #[test] - fn static_animation_frame_requires_static_vulkan_and_is_retained() { - let common = [ - "--root", - "testdata/IS", - "--mission", - "MISSIONS/Autodemo.00/data.tma", - "--static-animation-frame", - "12", - ]; - assert_eq!( - Args::parse(&strings(&common)), - Err("--static-animation-frame requires --backend static-vulkan".to_string()) - ); + fn parses_static_animation_frame() { let parsed = Args::parse(&strings(&[ "--root", "testdata/IS", "--mission", "MISSIONS/Autodemo.00/data.tma", - "--backend", - "static-vulkan", "--static-animation-frame", "12", ])) @@ -1547,26 +1045,12 @@ mod tests { } #[test] - fn static_material_phase_requires_static_vulkan_and_is_retained() { - let common = [ - "--root", - "testdata/IS", - "--mission", - "MISSIONS/Autodemo.00/data.tma", - "--static-material-phase", - "2", - ]; - assert_eq!( - Args::parse(&strings(&common)), - Err("--static-material-phase requires --backend static-vulkan".to_string()) - ); + fn parses_static_material_phase() { let parsed = Args::parse(&strings(&[ "--root", "testdata/IS", "--mission", "MISSIONS/Autodemo.00/data.tma", - "--backend", - "static-vulkan", "--static-material-phase", "2", ])) @@ -1591,52 +1075,12 @@ mod tests { } #[test] - fn parses_load_progress_path() { - assert_eq!( - Args::parse(&strings(&[ - "--root", - "testdata/IS", - "--mission", - "MISSIONS/Autodemo.00/data.tma", - "--load-progress", - "target/probe.txt", - ])), - Ok(Args { - root: PathBuf::from("testdata/IS"), - mission: "MISSIONS/Autodemo.00/data.tma".to_string(), - frames: 1, - backend: RenderBackendMode::Planning, - load_progress: Some(PathBuf::from("target/probe.txt")), - readback_out: None, - preview_roots: NonZeroUsize::MAX, - legacy_camera_capture: None, - static_animation_frame: None, - static_material_phase: None, - }) - ); - } - - #[test] - fn legacy_camera_capture_requires_static_vulkan_and_is_retained() { - let common = [ - "--root", - "testdata/IS", - "--mission", - "MISSIONS/Autodemo.00/data.tma", - "--legacy-camera-capture", - "target/camera.json", - ]; - assert_eq!( - Args::parse(&strings(&common)), - Err("--legacy-camera-capture requires --backend static-vulkan".to_string()) - ); + fn parses_legacy_camera_capture_path() { let valid = Args::parse(&strings(&[ "--root", "testdata/IS", "--mission", "MISSIONS/Autodemo.00/data.tma", - "--backend", - "static-vulkan", "--legacy-camera-capture", "target/camera.json", ])) @@ -1691,38 +1135,6 @@ mod tests { } #[test] - fn load_progress_retains_prior_milestones() -> Result<(), String> { - let path = - std::env::temp_dir().join(format!("fparkan-game-progress-{}.txt", std::process::id())); - prepare_load_progress_path(&path)?; - write_load_progress( - &path, - std::time::Duration::from_millis(12), - MissionLoadPhase::GraphVisualMaterials, - )?; - write_load_progress( - &path, - std::time::Duration::from_millis(34), - MissionLoadPhase::GraphVisualMaterialRequests { - request_count: 64, - graph_node_count: 128, - graph_edge_count: 192, - wear_cache_entries: 8, - material_cache_entries: 16, - texture_cache_entries: 32, - }, - )?; - let progress = std::fs::read_to_string(&path).map_err(|err| err.to_string())?; - std::fs::remove_file(&path).map_err(|err| err.to_string())?; - - assert_eq!( - progress, - "Starting\telapsed_ms=0\nGraphVisualMaterials\telapsed_ms=12\nGraphVisualMaterialRequests { request_count: 64, graph_node_count: 128, graph_edge_count: 192, wear_cache_entries: 8, material_cache_entries: 16, texture_cache_entries: 32 }\telapsed_ms=34\n" - ); - Ok(()) - } - - #[test] fn static_preview_component_merge_offsets_indices_and_remaps_local_selectors( ) -> Result<(), String> { let mut merged = VulkanStaticMesh { @@ -1773,267 +1185,4 @@ mod tests { assert_eq!(merged.indices, vec![65_536, 65_537, 65_538]); Ok(()) } - - #[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, - }, - ], - transforms: Vec::new(), - events: Vec::new(), - hash: StateHash([0; 32]), - }; - - let commands = render_snapshot_commands(&snapshot)?; - - assert_eq!(commands.commands.len(), 4); - assert!(matches!( - commands.commands[0], - fparkan_render::RenderCommand::BeginFrame - )); - assert!(matches!( - commands.commands[3], - fparkan_render::RenderCommand::EndFrame - )); - let fparkan_render::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 render_snapshot_retains_every_prepared_visual_for_an_object() -> Result<(), String> { - let snapshot = WorldSnapshot { - tick: Tick(3), - objects: vec![ObjectHandle { - generation: 1, - slot: 7, - }], - transforms: Vec::new(), - events: Vec::new(), - hash: StateHash([0; 32]), - }; - let first = prepared_visual(101, 201); - let second = prepared_visual(102, 202); - let assets = MissionAssets { - visuals: vec![first, second], - object_visuals: vec![vec![AssetId::new(101), AssetId::new(102)]], - ..MissionAssets::default() - }; - let commands = render_snapshot_commands_with_assets(&snapshot, Some(&assets), None)?; - let draws: Vec<_> = commands - .commands - .iter() - .filter_map(|command| match command { - RenderCommand::Draw(draw) => Some(draw), - _ => None, - }) - .collect(); - assert_eq!(draws.len(), 2); - assert_eq!(draws[0].mesh, GpuMeshId(101)); - assert_eq!(draws[1].mesh, GpuMeshId(102)); - assert_eq!(draws[0].material, GpuMaterialId(201)); - assert_eq!(draws[1].material, GpuMaterialId(202)); - assert_ne!(draws[0].stable_order, draws[1].stable_order); - Ok(()) - } - - #[test] - fn render_snapshot_uses_mission_position_and_scale_without_interpreting_orientation() { - let draft = MissionObjectDraft { - original_id: None, - resource_name_raw: Vec::new(), - identity_or_clan_raw: 0, - position: [10.0, 20.0, 30.0], - orientation_raw: [1.0, 2.0, 3.0], - scale: [2.0, 3.0, 4.0], - visual_ids: Vec::new(), - properties: Vec::new(), - unit_components: Vec::new(), - }; - assert_eq!( - mission_position_scale_transform(&draft), - [2.0, 0.0, 0.0, 10.0, 0.0, 3.0, 0.0, 20.0, 0.0, 0.0, 4.0, 30.0, 0.0, 0.0, 0.0, 1.0,] - ); - } - - #[test] - fn render_snapshot_prefers_world_transform_over_mission_draft() -> Result<(), String> { - let handle = ObjectHandle { - generation: 1, - slot: 0, - }; - let snapshot = WorldSnapshot { - tick: Tick(1), - objects: vec![handle], - transforms: vec![( - handle, - TransformState { - position: [ - 100.0_f32.to_bits(), - 200.0_f32.to_bits(), - 300.0_f32.to_bits(), - ], - orientation: [0; 3], - scale: [4.0_f32.to_bits(), 5.0_f32.to_bits(), 6.0_f32.to_bits()], - }, - )], - events: Vec::new(), - hash: StateHash([0; 32]), - }; - let drafts = vec![MissionObjectDraft { - original_id: None, - resource_name_raw: Vec::new(), - identity_or_clan_raw: 0, - position: [1.0; 3], - orientation_raw: [0.0; 3], - scale: [1.0; 3], - visual_ids: Vec::new(), - properties: Vec::new(), - unit_components: Vec::new(), - }]; - - let commands = render_snapshot_commands_with_assets(&snapshot, None, Some(&drafts))?; - let RenderCommand::Draw(draw) = &commands.commands[1] else { - return Err("expected draw".to_string()); - }; - assert_eq!( - draw.transform, - [ - 4.0, 0.0, 0.0, 100.0, 0.0, 5.0, 0.0, 200.0, 0.0, 0.0, 6.0, 300.0, 0.0, 0.0, 0.0, - 1.0 - ] - ); - Ok(()) - } - - #[test] - fn render_snapshot_preserves_mission_original_object_id() -> Result<(), String> { - let snapshot = WorldSnapshot { - tick: Tick(1), - objects: vec![ObjectHandle { - generation: 1, - slot: 0, - }], - transforms: Vec::new(), - events: Vec::new(), - hash: StateHash([0; 32]), - }; - let drafts = vec![MissionObjectDraft { - original_id: Some(OriginalObjectId(42)), - resource_name_raw: Vec::new(), - identity_or_clan_raw: 0, - position: [0.0; 3], - orientation_raw: [0.0; 3], - scale: [1.0; 3], - visual_ids: Vec::new(), - properties: Vec::new(), - unit_components: Vec::new(), - }]; - - let commands = render_snapshot_commands_with_assets(&snapshot, None, Some(&drafts))?; - let RenderCommand::Draw(draw) = &commands.commands[1] else { - return Err("expected draw".to_string()); - }; - assert_eq!(draw.object_id, Some(OriginalObjectId(42))); - Ok(()) - } - - fn prepared_visual(id: u64, material: u64) -> PreparedVisual { - PreparedVisual { - id: AssetId::new(id), - mesh: None, - model_id: None, - wear_id: None, - model_nodes: 0, - model_slots: 0, - model_batches: 0, - material_count: 1, - material_ids: vec![AssetId::new(material)], - texture_ids: Vec::new(), - lightmap_ids: Vec::new(), - texture_count: 0, - lightmap_count: 0, - } - } - - #[test] - #[ignore = "requires licensed corpus"] - 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: "{\"report_kind\":\"render-planning\",\"backend\":\"vulkan-planning\",\"window\":\"synthetic\",\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"submission_plans\":1,\"last_command_capture_bytes\":2008,\"hash\":\"ca17cc76e55c45e83c1c9c1c088e84bf1a698be91a7730943210fe27596af841\"}", - }, - RenderCase { - root: "IS2", - mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma", - expected: "{\"report_kind\":\"render-planning\",\"backend\":\"vulkan-planning\",\"window\":\"synthetic\",\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"submission_plans\":1,\"last_command_capture_bytes\":605,\"hash\":\"5d720b3ab690076a398a79a404850bbeaee2e33811b5bb570ec8a96d4a7a2fc4\"}", - }, - ] { - assert_eq!( - run(&render_args(&licensed_root(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 licensed_root(name: &str) -> PathBuf { - let variable = match name { - "IS" => "FPARKAN_CORPUS_PART1_ROOT", - "IS2" => "FPARKAN_CORPUS_PART2_ROOT", - _ => panic!("unknown licensed corpus part: {name}"), - }; - let root = std::env::var_os(variable) - .map(PathBuf::from) - .unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests")); - assert!( - root.is_dir(), - "licensed corpus root is missing: {}", - root.display() - ); - root - } } diff --git a/apps/fparkan-headless/Cargo.toml b/apps/fparkan-headless/Cargo.toml deleted file mode 100644 index 483438b..0000000 --- a/apps/fparkan-headless/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "fparkan-headless" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" } -fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" } -fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" } - -[lints] -workspace = true diff --git a/apps/fparkan-headless/src/main.rs b/apps/fparkan-headless/src/main.rs deleted file mode 100644 index 9c5ab52..0000000 --- a/apps/fparkan-headless/src/main.rs +++ /dev/null @@ -1,267 +0,0 @@ -#![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] -#![allow(clippy::print_stderr, clippy::print_stdout)] -//! `FParkan` headless runtime entrypoint. - -use fparkan_runtime::{ - advance_reference_movement, create, load_mission, step_headless, EngineConfig, EngineMode, - EngineServices, MissionRequest, -}; -use fparkan_vfs::DirectoryVfs; -use fparkan_world::{InputSnapshot, OriginalObjectId}; -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={} scripts={} script_events={} script_varset_declarations={} script_init_states={} script_varset_states={} 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.script_bundle_count, - loaded.script_event_count, - loaded.script_varset_declaration_count, - loaded.script_init_state_count, - loaded.script_varset_state_count, - loaded.graph_failure_count - ); - } - if let Some(movement) = args.reference_movement { - let reached = advance_reference_movement( - &mut engine, - OriginalObjectId(movement.original_id), - movement.target_xy, - movement.max_step, - ) - .map_err(|err| format!("{err}"))?; - println!( - "reference_movement original_id={} target_xy=[{},{}] max_step={} reached={reached}", - movement.original_id, movement.target_xy[0], movement.target_xy[1], movement.max_step, - ); - } - 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(()) -} - -#[derive(Debug)] -struct Args { - root: Option<PathBuf>, - mission: Option<String>, - ticks: u64, - reference_movement: Option<ReferenceMovement>, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct ReferenceMovement { - original_id: u32, - target_xy: [f32; 2], - max_step: f32, -} - -impl Args { - fn parse(args: &[String]) -> Result<Self, String> { - let mut parsed = Self { - root: None, - mission: None, - ticks: 1, - reference_movement: None, - }; - 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())?; - } - "--move-object" => { - if parsed.reference_movement.is_some() { - return Err("--move-object may be specified once".to_string()); - } - let original_id = iter - .next() - .ok_or_else(|| "--move-object requires an original object id".to_string())? - .parse() - .map_err(|_| { - "--move-object object id must be an unsigned integer".to_string() - })?; - let x = parse_finite_argument( - iter.next(), - "--move-object requires a finite X target", - )?; - let y = parse_finite_argument( - iter.next(), - "--move-object requires a finite Y target", - )?; - let max_step = parse_finite_argument( - iter.next(), - "--move-object requires a finite positive maximum step", - )?; - if max_step <= 0.0 { - return Err("--move-object maximum step must be positive".to_string()); - } - parsed.reference_movement = Some(ReferenceMovement { - original_id, - target_xy: [x, y], - max_step, - }); - } - _ => return Err(usage()), - } - } - if parsed.mission.is_some() && parsed.root.is_none() { - return Err("--mission requires --root".to_string()); - } - if parsed.reference_movement.is_some() && parsed.mission.is_none() { - return Err("--move-object requires --mission".to_string()); - } - Ok(parsed) - } -} - -fn parse_finite_argument(value: Option<&String>, error: &str) -> Result<f32, String> { - let value: f32 = value - .ok_or_else(|| error.to_string())? - .parse() - .map_err(|_| error.to_string())?; - value - .is_finite() - .then_some(value) - .ok_or_else(|| error.to_string()) -} - -fn usage() -> String { - "usage: fparkan-headless [--root <path> --mission <path>] [--move-object <original-id> <x> <y> <max-step>] [--ticks <n>]".to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn args(values: &[&str]) -> Vec<String> { - values.iter().map(ToString::to_string).collect() - } - - #[test] - fn move_object_parses_a_single_finite_reference_command() { - let parsed = Args::parse(&args(&[ - "--root", - "C:/game", - "--mission", - "MISSIONS/Autodemo.00/data.tma", - "--move-object", - "7", - "12.5", - "-3", - "0.25", - "--ticks", - "2", - ])) - .expect("args"); - assert_eq!(parsed.ticks, 2); - assert_eq!( - parsed.reference_movement, - Some(ReferenceMovement { - original_id: 7, - target_xy: [12.5, -3.0], - max_step: 0.25, - }) - ); - } - - #[test] - fn move_object_requires_a_loaded_mission_and_valid_step() { - assert_eq!( - Args::parse(&args(&["--move-object", "7", "1", "2", "1"])).expect_err("mission"), - "--move-object requires --mission" - ); - assert_eq!( - Args::parse(&args(&[ - "--root", - "C:/game", - "--mission", - "M/data.tma", - "--move-object", - "7", - "1", - "2", - "0", - ])) - .expect_err("step"), - "--move-object maximum step must be positive" - ); - } -} diff --git a/apps/fparkan-viewer/Cargo.toml b/apps/fparkan-viewer/Cargo.toml deleted file mode 100644 index 0ad6167..0000000 --- a/apps/fparkan-viewer/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "fparkan-viewer" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" } -fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" } - -[lints] -workspace = true diff --git a/apps/fparkan-viewer/src/main.rs b/apps/fparkan-viewer/src/main.rs deleted file mode 100644 index 5b4e26e..0000000 --- a/apps/fparkan-viewer/src/main.rs +++ /dev/null @@ -1,350 +0,0 @@ -#![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] -#![allow(clippy::print_stderr, clippy::print_stdout)] -//! `FParkan` asset inspection composition root. - -use fparkan_inspection::{ - inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection, - LandFileKind, MapInspection, NresEntrySummary, -}; -use fparkan_render::{ - build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase, - RenderProfile, RenderSnapshot, RenderSnapshotDraw, -}; -use std::fmt::Write; -use std::path::PathBuf; - -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 inspection = fparkan_inspection::inspect_archive_file(&file, limit)?; - - match inspection { - ArchiveInspection::Nres { - entries, - lookup_order_valid, - sample, - } => Ok(format!( - "{{\"report_kind\":\"archive-inspection\",\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}", - json_string(&file.display().to_string()), - entries, - lookup_order_valid, - render_nres_entries(&sample) - )), - ArchiveInspection::Rsli { entries } => Ok(format!( - "{{\"report_kind\":\"archive-inspection\",\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}", - json_string(&file.display().to_string()), - entries - )), - ArchiveInspection::Unsupported => 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 inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?; - - Ok(format!( - "{{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}", - json_string(&query.archive), - json_string(&query.name), - inspection.streams, - inspection.nodes, - inspection.slots, - inspection.positions, - inspection.indices, - inspection.batches - )) -} - -#[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, - pipeline_state: fparkan_render::LegacyPipelineState::default(), - 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!( - "{{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model-inspection\",\"draw_commands\":{draw_commands}}}", - json_string(fixture) - )) - } -} - -fn inspect_texture(args: &[String]) -> Result<String, String> { - let query = parse_resource_query(args)?; - let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?; - - Ok(format!( - "{{\"report_kind\":\"texture-inspection\",\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}", - json_string(&query.archive), - json_string(&query.name), - inspection.width, - inspection.height, - json_string(&inspection.format), - inspection.mips, - inspection.pages - )) -} - -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 inspection = inspect_land_file( - &file, - match kind.as_str() { - "land-msh" => LandFileKind::LandMsh, - "land-map" => LandFileKind::LandMap, - _ => return Err(format!("unknown map kind: {kind}")), - }, - )?; - - Ok(render_map_inspection_json( - &file.display().to_string(), - &kind, - &inspection, - )) -} - -fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String { - match kind { - "land-msh" => format!( - "{{\"report_kind\":\"map-inspection\",\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}", - json_string(path), - inspection.streams, - inspection.positions, - inspection.faces, - inspection.slots - ), - "land-map" => format!( - "{{\"report_kind\":\"map-inspection\",\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}", - json_string(path), - inspection.areals, - inspection.declared_areals, - inspection.grid_width, - inspection.grid_height - ), - _ => unreachable!("invalid land 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 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(entries: &[NresEntrySummary]) -> String { - let mut out = String::new(); - for (index, entry) in entries.iter().enumerate() { - if index > 0 { - out.push(','); - } - let name = &entry.name; - let _ = write!( - out, - "{{\"name\":{},\"type\":{},\"size\":{}}}", - json_string(name), - entry.type_id, - entry.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}", c as u32); - } - 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"]))?, - "{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model-inspection\",\"draw_commands\":1}" - ); - Ok(()) - } -} diff --git a/apps/fparkan-vulkan-smoke/Cargo.toml b/apps/fparkan-vulkan-smoke/Cargo.toml deleted file mode 100644 index 4492c66..0000000 --- a/apps/fparkan-vulkan-smoke/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "fparkan-vulkan-smoke" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -build = "build.rs" - -[dependencies] -fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" } -fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" } -fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" } -fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -winit = { version = "0.30", default-features = false, features = ["rwh_06"] } - -[lints] -workspace = true diff --git a/apps/fparkan-vulkan-smoke/build.rs b/apps/fparkan-vulkan-smoke/build.rs deleted file mode 100644 index 24da219..0000000 --- a/apps/fparkan-vulkan-smoke/build.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! Build-time provenance for native smoke artifacts. - -use std::env; -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn main() { - println!("cargo:rerun-if-env-changed=TARGET"); - println!("cargo:rerun-if-env-changed=RUSTC"); - println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); - println!("cargo:rerun-if-env-changed=GITHUB_SHA"); - println!("cargo:rerun-if-env-changed=SOURCE_VERSION"); - println!("cargo:rerun-if-env-changed=BUILD_VCS_NUMBER"); - - if let Ok(target) = env::var("TARGET") { - println!("cargo:rustc-env=FPARKAN_BUILD_TARGET_TRIPLE={target}"); - } - if let Some(toolchain) = rustc_release() { - println!("cargo:rustc-env=FPARKAN_BUILD_RUST_TOOLCHAIN={toolchain}"); - } - - if let Some(workspace_root) = workspace_root() { - if let Some(git_dir) = git_dir(&workspace_root) { - emit_git_rerun_hints(&git_dir); - } - - if let Some(commit_sha) = env_commit_sha().or_else(|| git_head_commit_sha(&workspace_root)) - { - println!("cargo:rustc-env=FPARKAN_BUILD_COMMIT_SHA={commit_sha}"); - } - if let Some(git_dirty) = git_dirty(&workspace_root) { - println!("cargo:rustc-env=FPARKAN_BUILD_GIT_DIRTY={git_dirty}"); - } - } -} - -fn workspace_root() -> Option<PathBuf> { - env::var_os("CARGO_MANIFEST_DIR") - .map(PathBuf::from) - .map(|manifest_dir| manifest_dir.join("../..")) -} - -fn env_commit_sha() -> Option<String> { - ["GITHUB_SHA", "SOURCE_VERSION", "BUILD_VCS_NUMBER"] - .into_iter() - .filter_map(|name| env::var(name).ok()) - .find(|value| is_commit_sha(value)) -} - -fn git_head_commit_sha(workspace_root: &Path) -> Option<String> { - let output = Command::new("git") - .args(["-C"]) - .arg(workspace_root) - .args(["rev-parse", "HEAD"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let value = String::from_utf8(output.stdout).ok()?; - let value = value.trim().to_string(); - is_commit_sha(&value).then_some(value) -} - -fn git_dirty(workspace_root: &Path) -> Option<bool> { - let output = Command::new("git") - .args(["-C"]) - .arg(workspace_root) - .args(["status", "--short"]) - .output() - .ok()?; - output - .status - .success() - .then(|| !String::from_utf8_lossy(&output.stdout).trim().is_empty()) -} - -fn git_dir(workspace_root: &Path) -> Option<PathBuf> { - let output = Command::new("git") - .args(["-C"]) - .arg(workspace_root) - .args(["rev-parse", "--git-dir"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let value = String::from_utf8(output.stdout).ok()?; - let value = value.trim(); - (!value.is_empty()).then(|| workspace_root.join(value)) -} - -fn emit_git_rerun_hints(git_dir: &Path) { - let head = git_dir.join("HEAD"); - println!("cargo:rerun-if-changed={}", head.display()); - println!( - "cargo:rerun-if-changed={}", - git_dir.join("packed-refs").display() - ); - println!("cargo:rerun-if-changed={}", git_dir.join("index").display()); - let Some(reference) = std::fs::read_to_string(&head).ok().and_then(|value| { - value - .strip_prefix("ref: ") - .map(str::trim) - .map(ToOwned::to_owned) - }) else { - return; - }; - println!( - "cargo:rerun-if-changed={}", - git_dir.join(reference).display() - ); -} - -fn is_commit_sha(value: &str) -> bool { - value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()) -} - -fn rustc_release() -> Option<String> { - let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); - let output = Command::new(rustc).arg("-Vv").output().ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout) - .ok()? - .lines() - .find_map(|line| { - line.strip_prefix("release: ") - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - }) -} diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs deleted file mode 100644 index ec2d19c..0000000 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ /dev/null @@ -1,1973 +0,0 @@ -#![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::expect_used, - clippy::needless_raw_string_hashes, - clippy::panic, - clippy::unwrap_used - ) -)] -#![allow(clippy::print_stderr, clippy::print_stdout)] -//! Native Vulkan smoke runner entrypoint. - -use fparkan_platform::RenderRequest; -use fparkan_platform_winit::{window_native_handles, WinitWindowPlan}; -use fparkan_render_vulkan::{ - project_land_msh_to_static_mesh, project_msh_to_static_mesh, VulkanReadbackArtifact, - VulkanSmokeBootstrapProgress, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, - VulkanSmokeRendererCreateInfo, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, - VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, - VulkanValidationReport, -}; -use serde::Serialize; -use std::path::PathBuf; -use std::process::Command; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use winit::application::ApplicationHandler; -use winit::dpi::PhysicalSize; -use winit::event::StartCause; -use winit::event::WindowEvent; -use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; -use winit::window::{Window, WindowId}; - -const SCHEMA_VERSION: &str = "fparkan-native-smoke-v1"; -const DEFAULT_TARGET_FRAMES: u32 = 300; -const DEFAULT_RESIZE_FRAME: u32 = 120; -const DEFAULT_RESIZE_WIDTH: u32 = 960; -const DEFAULT_RESIZE_HEIGHT: u32 = 540; -const DEFAULT_TIMEOUT_SECONDS: u64 = 120; - -fn main() { - let args = std::env::args().skip(1).collect::<Vec<_>>(); - let code = match run(&args) { - Ok(output) => { - println!("{output}"); - 0 - } - Err(err) => { - eprintln!("{err}"); - 2 - } - }; - std::process::exit(code); -} - -fn run(args: &[String]) -> Result<String, String> { - let options = SmokeOptions::parse(args)?; - let mesh = options.load_mesh()?; - let materials = options.load_materials(&mesh)?; - remove_stale_output(&options)?; - let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; - event_loop.set_control_flow(ControlFlow::Poll); - let completed = Arc::new(AtomicBool::new(false)); - let progress = Arc::new(SharedSmokeProgress::default()); - spawn_timeout_watchdog( - options.clone(), - Arc::clone(&completed), - Arc::clone(&progress), - ); - let mut app = SmokeApp::new(options, mesh, materials, completed, progress); - if let Err(err) = event_loop.run_app(&mut app) { - app.error = Some(format!("winit event loop: {err}")); - } - app.finish() -} - -fn spawn_timeout_watchdog( - options: SmokeOptions, - completed: Arc<AtomicBool>, - progress: Arc<SharedSmokeProgress>, -) { - std::thread::spawn(move || { - std::thread::sleep(Duration::from_secs(options.timeout_seconds)); - if completed.load(Ordering::SeqCst) { - return; - } - let failure_reason = format!( - "native smoke timed out after {} seconds", - options.timeout_seconds - ); - if let Ok(report) = render_timeout_failure_report(&options, &failure_reason, &progress) { - if let Some(parent) = options.out.parent() { - let _ = std::fs::create_dir_all(parent); - } - let _ = std::fs::write(&options.out, report); - } - eprintln!("{failure_reason}"); - std::process::exit(2); - }); -} - -fn remove_stale_output(options: &SmokeOptions) -> Result<(), String> { - if !options.out.exists() { - return Ok(()); - } - std::fs::remove_file(&options.out).map_err(|err| format!("{}: {err}", options.out.display())) -} - -fn compare_readback_files( - expected: &std::path::Path, - actual: &std::path::Path, -) -> Result<(), String> { - let expected_format = readback_format_from_path(expected)?; - let actual_format = readback_format_from_path(actual)?; - if expected_format != actual_format { - return Err(format!( - "pixel readback format mismatch: expected={expected_format} actual={actual_format}" - )); - } - let expected_bytes = - std::fs::read(expected).map_err(|err| format!("{}: {err}", expected.display()))?; - let actual_bytes = - std::fs::read(actual).map_err(|err| format!("{}: {err}", actual.display()))?; - if expected_bytes.len() != actual_bytes.len() { - return Err(format!( - "pixel readback length mismatch: expected={} actual={}", - expected_bytes.len(), - actual_bytes.len() - )); - } - if let Some((offset, (expected_byte, actual_byte))) = expected_bytes - .iter() - .zip(&actual_bytes) - .enumerate() - .find(|(_, (expected_byte, actual_byte))| expected_byte != actual_byte) - { - return Err(format!( - "pixel readback mismatch at byte {offset}: expected={expected_byte} actual={actual_byte}" - )); - } - Ok(()) -} - -fn readback_format_from_path(path: &std::path::Path) -> Result<i32, String> { - let name = path - .file_name() - .and_then(std::ffi::OsStr::to_str) - .ok_or_else(|| { - format!( - "pixel readback artifact has no UTF-8 file name: {}", - path.display() - ) - })?; - let prefix = ".readback-vkformat-"; - let raw = name - .strip_suffix(".raw") - .and_then(|stem| stem.rsplit_once(prefix).map(|(_, raw)| raw)) - .ok_or_else(|| { - format!( - "pixel readback artifact lacks format identity: {}", - path.display() - ) - })?; - raw.parse::<i32>().map_err(|_| { - format!( - "pixel readback artifact has invalid format identity: {}", - path.display() - ) - }) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct SmokeOptions { - out: PathBuf, - frames: u32, - resize_frame: u32, - timeout_seconds: u64, - expected_readback: Option<PathBuf>, - mesh_input: MeshInput, - texture_input: Option<TextureInput>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ResourceInput { - root: PathBuf, - archive: String, - name: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct WearMaterialInput { - root: PathBuf, - archive: String, - name: String, - material_index: u16, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct WearInput { - root: PathBuf, - archive: String, - name: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum TextureInput { - Direct(ResourceInput), - WearMaterial(WearMaterialInput), - WearBatchMaterials(WearInput), -} - -impl TextureInput { - fn source(&self) -> &'static str { - match self { - Self::Direct(_) => "original-texm", - Self::WearMaterial(_) => "original-wear-mat0-texm", - Self::WearBatchMaterials(_) => "original-wear-mat0-texm-batch", - } - } - - fn archive(&self) -> &str { - match self { - Self::Direct(input) => &input.archive, - Self::WearMaterial(_) | Self::WearBatchMaterials(_) => "Textures.lib", - } - } - - fn requested_name(&self) -> &str { - match self { - Self::Direct(input) => &input.name, - // The selected name comes from MAT0 at load time and is not a CLI request. - Self::WearMaterial(_) | Self::WearBatchMaterials(_) => "", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum MeshInput { - SmokeTriangle, - Msh { - root: PathBuf, - archive: String, - name: String, - }, - LandMsh { - path: PathBuf, - }, -} - -impl MeshInput { - fn kind(&self) -> &'static str { - match self { - Self::SmokeTriangle => "synthetic-smoke-triangle", - Self::Msh { .. } => "original-msh", - Self::LandMsh { .. } => "original-land-msh", - } - } - - fn archive(&self) -> &str { - match self { - Self::Msh { archive, .. } => archive, - Self::SmokeTriangle | Self::LandMsh { .. } => "", - } - } - - fn name(&self) -> &str { - match self { - Self::SmokeTriangle => "", - Self::Msh { name, .. } => name, - Self::LandMsh { .. } => "Land.msh", - } - } -} - -impl SmokeOptions { - #[allow(clippy::too_many_lines)] - fn parse(args: &[String]) -> Result<Self, String> { - let mut out = None; - let mut frames = DEFAULT_TARGET_FRAMES; - let mut resize_frame = DEFAULT_RESIZE_FRAME; - let mut timeout_seconds = DEFAULT_TIMEOUT_SECONDS; - let mut expected_readback = None; - let mut model_root = None; - let mut model_archive = None; - let mut model_name = None; - let mut terrain_path = None; - let mut texture_root = None; - let mut texture_archive = None; - let mut texture_name = None; - let mut wear_root = None; - let mut wear_archive = None; - let mut wear_name = None; - let mut material_index = None; - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - match arg.as_str() { - "--out" => { - out = Some( - iter.next() - .map(PathBuf::from) - .ok_or_else(|| "--out 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())?; - } - "--resize-frame" => { - resize_frame = iter - .next() - .ok_or_else(|| "--resize-frame requires a value".to_string())? - .parse() - .map_err(|_| "--resize-frame must be an integer".to_string())?; - } - "--timeout-seconds" => { - timeout_seconds = iter - .next() - .ok_or_else(|| "--timeout-seconds requires a value".to_string())? - .parse() - .map_err(|_| "--timeout-seconds must be an integer".to_string())?; - } - "--expected-readback" => { - expected_readback = Some( - iter.next() - .map(PathBuf::from) - .ok_or_else(|| "--expected-readback requires a path".to_string())?, - ); - } - "--model-root" => { - model_root = Some( - iter.next() - .map(PathBuf::from) - .ok_or_else(|| "--model-root requires a path".to_string())?, - ); - } - "--model-archive" => { - model_archive = Some( - iter.next() - .cloned() - .ok_or_else(|| "--model-archive requires a value".to_string())?, - ); - } - "--model-name" => { - model_name = Some( - iter.next() - .cloned() - .ok_or_else(|| "--model-name requires a value".to_string())?, - ); - } - "--terrain-path" => { - terrain_path = Some( - iter.next() - .map(PathBuf::from) - .ok_or_else(|| "--terrain-path requires a path".to_string())?, - ); - } - "--texture-root" => { - texture_root = Some( - iter.next() - .map(PathBuf::from) - .ok_or_else(|| "--texture-root requires a path".to_string())?, - ); - } - "--texture-archive" => { - texture_archive = Some( - iter.next() - .cloned() - .ok_or_else(|| "--texture-archive requires a value".to_string())?, - ); - } - "--texture-name" => { - texture_name = Some( - iter.next() - .cloned() - .ok_or_else(|| "--texture-name requires a value".to_string())?, - ); - } - "--wear-root" => { - wear_root = Some( - iter.next() - .map(PathBuf::from) - .ok_or_else(|| "--wear-root requires a path".to_string())?, - ); - } - "--wear-archive" => { - wear_archive = Some( - iter.next() - .cloned() - .ok_or_else(|| "--wear-archive requires a value".to_string())?, - ); - } - "--wear-name" => { - wear_name = Some( - iter.next() - .cloned() - .ok_or_else(|| "--wear-name requires a value".to_string())?, - ); - } - "--material-index" => { - material_index = Some( - iter.next() - .ok_or_else(|| "--material-index requires a value".to_string())? - .parse() - .map_err(|_| "--material-index must be a u16".to_string())?, - ); - } - _ => return Err(format!("unknown native smoke option: {arg}")), - } - } - let out = out.ok_or_else(|| "missing --out".to_string())?; - if frames < DEFAULT_TARGET_FRAMES { - return Err(format!( - "native smoke requires --frames >= {DEFAULT_TARGET_FRAMES}" - )); - } - if timeout_seconds == 0 { - return Err("native smoke requires --timeout-seconds >= 1".to_string()); - } - let mesh_input = match (terrain_path, model_root, model_archive, model_name) { - (Some(path), None, None, None) => MeshInput::LandMsh { path }, - (None, None, None, None) => MeshInput::SmokeTriangle, - (None, Some(root), Some(archive), Some(name)) => MeshInput::Msh { - root, - archive, - name, - }, - (None, _, _, _) => { - return Err( - "--model-root, --model-archive and --model-name must be supplied together" - .to_string(), - ); - } - _ => return Err("--terrain-path cannot be combined with --model-*".to_string()), - }; - let texture_input = match (texture_root, texture_archive, texture_name) { - (None, None, None) => None, - (Some(root), Some(archive), Some(name)) => Some(TextureInput::Direct(ResourceInput { - root, - archive, - name, - })), - _ => { - return Err( - "--texture-root, --texture-archive and --texture-name must be supplied together" - .to_string(), - ); - } - }; - let wear_material_input = match (wear_root, wear_archive, wear_name, material_index) { - (None, None, None, None) => None, - (Some(root), Some(archive), Some(name), Some(material_index)) => { - Some(TextureInput::WearMaterial(WearMaterialInput { - root, - archive, - name, - material_index, - })) - } - (Some(root), Some(archive), Some(name), None) => { - Some(TextureInput::WearBatchMaterials(WearInput { - root, - archive, - name, - })) - } - _ => { - return Err( - "--wear-root, --wear-archive and --wear-name must be supplied together; --material-index is optional" - .to_string(), - ); - } - }; - if texture_input.is_some() && wear_material_input.is_some() { - return Err( - "manual --texture-* input cannot be combined with --wear-* material input" - .to_string(), - ); - } - Ok(Self { - out, - frames, - resize_frame, - timeout_seconds, - expected_readback, - mesh_input, - texture_input: texture_input.or(wear_material_input), - }) - } - - fn load_mesh(&self) -> Result<VulkanStaticMesh, String> { - match &self.mesh_input { - MeshInput::SmokeTriangle => Ok(VulkanStaticMesh::smoke_triangle()), - MeshInput::Msh { - root, - archive, - name, - } => { - let model = fparkan_inspection::load_model_from_root(root, archive, name)?; - project_msh_to_static_mesh(&model).map_err(|err| err.to_string()) - } - MeshInput::LandMsh { path } => { - let terrain = fparkan_inspection::load_land_msh_from_path(path)?; - project_land_msh_to_static_mesh(&terrain).map_err(|err| err.to_string()) - } - } - } - - fn load_materials(&self, mesh: &VulkanStaticMesh) -> Result<Vec<LoadedTexture>, String> { - let Some(input) = self.texture_input.as_ref() else { - return Ok(Vec::new()); - }; - match input { - TextureInput::Direct(input) => Ok(vec![Self::load_material( - 0, - { - let image = fparkan_inspection::load_texture_mip0_rgba8_from_root( - &input.root, - &input.archive, - &input.name, - )?; - (image.width, image.height, image.rgba8) - }, - input.name.clone(), - )]), - TextureInput::WearMaterial(input) => { - let selected = fparkan_inspection::load_wear_material_texture_mip0_rgba8_from_root( - &input.root, - &input.archive, - &input.name, - input.material_index, - )?; - Ok(vec![Self::load_material( - input.material_index, - ( - selected.image.width, - selected.image.height, - selected.image.rgba8, - ), - selected.texture_name, - )]) - } - TextureInput::WearBatchMaterials(input) => { - let mut selectors = mesh - .draw_ranges - .iter() - .map(|range| range.material_index) - .collect::<Vec<_>>(); - selectors.sort_unstable(); - selectors.dedup(); - selectors - .into_iter() - .map(|material_index| { - let selected = - fparkan_inspection::load_wear_material_texture_mip0_rgba8_from_root( - &input.root, - &input.archive, - &input.name, - material_index, - )?; - Ok(Self::load_material( - material_index, - ( - selected.image.width, - selected.image.height, - selected.image.rgba8, - ), - selected.texture_name, - )) - }) - .collect() - } - } - } - - fn load_material( - material_index: u16, - image: (u32, u32, Vec<u8>), - selected_name: String, - ) -> LoadedTexture { - LoadedTexture { - material_index, - texture: VulkanStaticTexture { - width: image.0, - height: image.1, - rgba8: image.2, - }, - selected_name, - } - } -} - -#[derive(Clone, Debug)] -struct LoadedTexture { - material_index: u16, - texture: VulkanStaticTexture, - selected_name: String, -} - -struct SmokeApp { - options: SmokeOptions, - mesh: VulkanStaticMesh, - materials: Vec<LoadedTexture>, - completed: Arc<AtomicBool>, - progress: Arc<SharedSmokeProgress>, - window_id: Option<WindowId>, - window: Option<Window>, - renderer: Option<VulkanSmokeRenderer>, - final_renderer: Option<RendererSnapshot>, - error: Option<String>, - output: Option<String>, - frames_presented: u32, - resize_count: u32, - resize_requested: bool, - last_size: Option<(u32, u32)>, - started_at: Instant, -} - -fn drop_renderer_before_window<Renderer, WindowLike>( - renderer: &mut Option<Renderer>, - window: &mut Option<WindowLike>, -) { - drop(renderer.take()); - drop(window.take()); -} - -#[derive(Clone, Debug)] -struct RendererSnapshot { - report: VulkanSmokeRendererReport, - readback_artifact: Option<VulkanReadbackArtifact>, - swapchain_recreate_count: u32, - validation: VulkanValidationReport, -} - -impl From<VulkanSmokeShutdownReport> for RendererSnapshot { - fn from(report: VulkanSmokeShutdownReport) -> Self { - Self { - report: report.renderer_report, - readback_artifact: report.readback_artifact, - swapchain_recreate_count: report.swapchain_recreate_count, - validation: report.validation, - } - } -} - -impl SmokeApp { - fn new( - options: SmokeOptions, - mesh: VulkanStaticMesh, - materials: Vec<LoadedTexture>, - completed: Arc<AtomicBool>, - progress: Arc<SharedSmokeProgress>, - ) -> Self { - Self { - options, - mesh, - materials, - completed, - progress, - window_id: None, - window: None, - renderer: None, - final_renderer: None, - error: None, - output: None, - frames_presented: 0, - resize_count: 0, - resize_requested: false, - last_size: None, - started_at: Instant::now(), - } - } - - fn finish(mut self) -> Result<String, String> { - if let Some(output) = self.output.take() { - return Ok(output); - } - self.completed.store(true, Ordering::SeqCst); - let error = self - .error - .clone() - .unwrap_or_else(|| "native smoke exited before producing a report".to_string()); - self.write_failure_report(&error)?; - Err(error) - } - - fn schedule_next_redraw(&self) { - if let Some(window) = self.window.as_ref() { - window.request_redraw(); - } - } - - fn write_report(&self, report: &str) -> Result<(), String> { - if let Some(parent) = self.options.out.parent() { - std::fs::create_dir_all(parent) - .map_err(|err| format!("{}: {err}", parent.display()))?; - } - std::fs::write(&self.options.out, report) - .map_err(|err| format!("{}: {err}", self.options.out.display())) - } - - fn readback_artifact_path(&self, artifact: &VulkanReadbackArtifact) -> PathBuf { - let stem = self - .options - .out - .file_stem() - .and_then(std::ffi::OsStr::to_str) - .unwrap_or("smoke"); - self.options - .out - .with_file_name(format!("{stem}.readback-vkformat-{}.raw", artifact.format)) - } - - fn write_readback_artifact( - &self, - artifact: &VulkanReadbackArtifact, - ) -> Result<PathBuf, String> { - let path = self.readback_artifact_path(artifact); - std::fs::write(&path, &artifact.bytes) - .map_err(|err| format!("{}: {err}", path.display())) - .map(|()| path) - } - - fn live_renderer_snapshot(&self) -> Option<RendererSnapshot> { - self.renderer.as_ref().map(|renderer| RendererSnapshot { - report: renderer.report().clone(), - readback_artifact: None, - swapchain_recreate_count: renderer.swapchain_recreate_count(), - validation: renderer.validation_report(), - }) - } - - fn renderer_snapshot(&self) -> Option<RendererSnapshot> { - self.final_renderer - .clone() - .or_else(|| self.live_renderer_snapshot()) - } - - #[allow(clippy::too_many_lines)] - fn render_report( - &self, - status: &'static str, - failure_reason: Option<&str>, - ) -> Result<String, String> { - let renderer = self.renderer_snapshot(); - let validation = renderer - .as_ref() - .map_or_else(VulkanValidationReport::default, |snapshot| { - snapshot.validation.clone() - }); - let smoke_report = SmokeReport { - schema_version: SCHEMA_VERSION, - commit_sha: compiled_commit_sha(), - git_dirty: compiled_git_dirty(), - runner_identity: measured_runner_identity(), - runner_architecture: actual_architecture(), - rust_toolchain: compiled_rust_toolchain(), - target_triple: compiled_target_triple(), - platform: actual_platform(), - status, - failure_reason, - frames: self.frames_presented, - resize_count: self.resize_count, - swapchain_recreate_count: renderer - .as_ref() - .map_or(0, |snapshot| snapshot.swapchain_recreate_count), - validation_warning_count: validation.warning_count, - validation_error_count: validation.error_count, - validation_vuids: &validation.vuids, - requested_frames: self.options.frames, - timeout_seconds: self.options.timeout_seconds, - mesh_source: self.options.mesh_input.kind(), - mesh_archive: self.options.mesh_input.archive(), - mesh_name: self.options.mesh_input.name(), - mesh_vertex_count: self.mesh.vertices.len(), - mesh_index_count: self.mesh.indices.len(), - mesh_draw_count: self.mesh.draw_ranges.len(), - texture_source: self - .options - .texture_input - .as_ref() - .map_or("none", TextureInput::source), - texture_archive: self - .options - .texture_input - .as_ref() - .map_or("", TextureInput::archive), - texture_name: match self.materials.as_slice() { - [] => self - .options - .texture_input - .as_ref() - .map_or("", TextureInput::requested_name), - [material] => material.selected_name.as_str(), - _ => "multiple", - }, - texture_width: self - .materials - .first() - .map_or(0, |material| material.texture.width), - texture_height: self - .materials - .first() - .map_or(0, |material| material.texture.height), - material_binding_count: self.materials.len(), - mesh_material_indices: self - .mesh - .draw_ranges - .iter() - .map(|range| range.material_index) - .collect(), - material_texture_names: self - .materials - .iter() - .map(|material| material.selected_name.clone()) - .collect(), - shader_manifest_hash: renderer - .as_ref() - .map_or("", |snapshot| snapshot.report.shader_manifest_hash.as_str()), - vulkan_loader_status: if renderer.is_some() { - "available" - } else { - "failed" - }, - vulkan_instance_status: if renderer.is_some() { - "created" - } else { - "failed" - }, - window_status: if self.window.is_some() { - "created" - } else { - "failed" - }, - vulkan_surface_status: if renderer.is_some() { - "created" - } else { - "failed" - }, - vulkan_device_status: if renderer.is_some() { - "selected" - } else { - "failed" - }, - vulkan_device_name: renderer - .as_ref() - .map_or("", |snapshot| snapshot.report.device_name.as_str()), - vulkan_logical_device_status: if renderer.is_some() { - "created" - } else { - "failed" - }, - vulkan_logical_device_graphics_queue_family: renderer - .as_ref() - .map_or(0, |snapshot| snapshot.report.graphics_queue_family), - vulkan_logical_device_present_queue_family: renderer - .as_ref() - .map_or(0, |snapshot| snapshot.report.present_queue_family), - vulkan_logical_device_enabled_extension_count: renderer - .as_ref() - .map_or(0, |snapshot| snapshot.report.enabled_extension_count), - vulkan_swapchain_status: if renderer.is_some() { - "created" - } else { - "failed" - }, - vulkan_swapchain_width: renderer - .as_ref() - .map_or(0, |snapshot| snapshot.report.swapchain_extent.0), - vulkan_swapchain_height: renderer - .as_ref() - .map_or(0, |snapshot| snapshot.report.swapchain_extent.1), - vulkan_swapchain_image_count: renderer - .as_ref() - .map_or(0, |snapshot| snapshot.report.swapchain_image_count), - vulkan_swapchain_image_format: renderer - .as_ref() - .map_or(0, |snapshot| snapshot.report.swapchain_image_format), - vulkan_swapchain_image_usage: renderer - .as_ref() - .map_or(0, |snapshot| snapshot.report.swapchain_image_usage), - vulkan_readback_copy_count: renderer - .as_ref() - .map_or(0, |snapshot| snapshot.report.readback_copy_count), - vulkan_readback_byte_count: renderer - .as_ref() - .map_or(0, |snapshot| snapshot.report.readback_byte_count), - vulkan_readback_fnv1a64: renderer - .as_ref() - .map_or(0, |snapshot| snapshot.report.readback_fnv1a64), - vulkan_portability_enumeration: renderer - .as_ref() - .is_some_and(|snapshot| snapshot.report.portability_enumeration), - vulkan_portability_subset_enabled: renderer - .as_ref() - .is_some_and(|snapshot| snapshot.report.portability_subset_enabled), - }; - serde_json::to_string_pretty(&smoke_report) - .map(|json| format!("{json}\n")) - .map_err(|err| format!("native smoke report serialization failed: {err}")) - } - - fn write_failure_report(&self, failure_reason: &str) -> Result<(), String> { - let report = self.render_report("failed", Some(failure_reason))?; - self.write_report(&report) - } - - fn abort_if_timed_out(&mut self, event_loop: &ActiveEventLoop) -> bool { - if self.output.is_some() || self.error.is_some() { - return false; - } - if self.started_at.elapsed() <= Duration::from_secs(self.options.timeout_seconds) { - return false; - } - self.error = Some(format!( - "native smoke timed out after {} seconds", - self.options.timeout_seconds - )); - event_loop.exit(); - true - } - - fn complete(&mut self, event_loop: &ActiveEventLoop) { - let Some(renderer) = self.renderer.as_ref() else { - self.error = Some("native smoke renderer was not initialized".to_string()); - event_loop.exit(); - return; - }; - if self.frames_presented < self.options.frames { - self.error = Some("native smoke did not reach the required frame count".to_string()); - event_loop.exit(); - return; - } - if self.resize_count == 0 || renderer.swapchain_recreate_count() == 0 { - self.error = Some( - "native smoke requires at least one measured resize and swapchain recreation" - .to_string(), - ); - event_loop.exit(); - return; - } - let Some(renderer) = self.renderer.take() else { - self.error = Some("native smoke renderer was not initialized".to_string()); - event_loop.exit(); - return; - }; - let final_renderer = match renderer.shutdown() { - Ok(report) => RendererSnapshot::from(report), - Err(err) => { - self.error = Some(err.to_string()); - event_loop.exit(); - return; - } - }; - if final_renderer.validation.warning_count != 0 - || final_renderer.validation.error_count != 0 - { - self.final_renderer = Some(final_renderer.clone()); - self.error = Some(format!( - "native smoke validation must stay clean (warnings={}, errors={})", - final_renderer.validation.warning_count, final_renderer.validation.error_count - )); - event_loop.exit(); - return; - } - self.final_renderer = Some(final_renderer); - if let Some(artifact) = self - .final_renderer - .as_ref() - .and_then(|snapshot| snapshot.readback_artifact.as_ref()) - { - let path = match self.write_readback_artifact(artifact) { - Ok(path) => path, - Err(err) => { - self.error = Some(err); - event_loop.exit(); - return; - } - }; - if let Some(expected) = self.options.expected_readback.as_ref() { - if let Err(err) = compare_readback_files(expected, &path) { - self.error = Some(err); - event_loop.exit(); - return; - } - } - } - let report = match self.render_report("passed", None) { - Ok(report) => report, - Err(err) => { - self.error = Some(err); - event_loop.exit(); - return; - } - }; - self.completed.store(true, Ordering::SeqCst); - if let Err(err) = self.write_report(&report) { - self.error = Some(err); - event_loop.exit(); - return; - } - self.output = Some(report); - event_loop.exit(); - } - - fn request_controlled_resize(&mut self) { - if self.resize_requested { - return; - } - let Some(window) = self.window.as_ref() else { - return; - }; - self.resize_requested = true; - let requested = PhysicalSize::new(DEFAULT_RESIZE_WIDTH, DEFAULT_RESIZE_HEIGHT); - let _ = window.request_inner_size(requested); - } -} - -impl Drop for SmokeApp { - fn drop(&mut self) { - // Keep the native window alive until the Vulkan renderer finishes - // destroying swapchain and surface state that still references it. - drop_renderer_before_window(&mut self.renderer, &mut self.window); - } -} - -fn render_timeout_failure_report( - options: &SmokeOptions, - failure_reason: &str, - progress: &SharedSmokeProgress, -) -> Result<String, String> { - let bootstrap = progress.bootstrap.snapshot(); - let smoke_report = SmokeReport { - schema_version: SCHEMA_VERSION, - commit_sha: compiled_commit_sha(), - git_dirty: compiled_git_dirty(), - runner_identity: measured_runner_identity(), - runner_architecture: actual_architecture(), - rust_toolchain: compiled_rust_toolchain(), - target_triple: compiled_target_triple(), - platform: actual_platform(), - status: "failed", - failure_reason: Some(failure_reason), - frames: progress.frames_presented.load(Ordering::SeqCst), - resize_count: progress.resize_count.load(Ordering::SeqCst), - swapchain_recreate_count: progress.swapchain_recreate_count.load(Ordering::SeqCst), - validation_warning_count: 0, - validation_error_count: 0, - validation_vuids: &[], - requested_frames: options.frames, - timeout_seconds: options.timeout_seconds, - mesh_source: options.mesh_input.kind(), - mesh_archive: options.mesh_input.archive(), - mesh_name: options.mesh_input.name(), - mesh_vertex_count: 0, - mesh_index_count: 0, - mesh_draw_count: 0, - texture_source: options - .texture_input - .as_ref() - .map_or("none", TextureInput::source), - texture_archive: options - .texture_input - .as_ref() - .map_or("", TextureInput::archive), - texture_name: options - .texture_input - .as_ref() - .map_or("", TextureInput::requested_name), - texture_width: 0, - texture_height: 0, - material_binding_count: 0, - mesh_material_indices: Vec::new(), - material_texture_names: Vec::new(), - shader_manifest_hash: "", - vulkan_loader_status: if bootstrap.loader_available { - "available" - } else { - "failed" - }, - vulkan_instance_status: if bootstrap.instance_created { - "created" - } else { - "failed" - }, - window_status: progress.window_phase.status(), - vulkan_surface_status: if bootstrap.surface_created { - "created" - } else { - "failed" - }, - vulkan_device_status: if bootstrap.device_selected { - "selected" - } else { - "failed" - }, - vulkan_device_name: "", - vulkan_logical_device_status: if bootstrap.logical_device_created { - "created" - } else { - "failed" - }, - vulkan_logical_device_graphics_queue_family: 0, - vulkan_logical_device_present_queue_family: 0, - vulkan_logical_device_enabled_extension_count: 0, - vulkan_swapchain_status: if bootstrap.swapchain_created { - "created" - } else { - "failed" - }, - vulkan_swapchain_width: 0, - vulkan_swapchain_height: 0, - vulkan_swapchain_image_count: 0, - vulkan_swapchain_image_format: 0, - vulkan_swapchain_image_usage: 0, - vulkan_readback_copy_count: 0, - vulkan_readback_byte_count: 0, - vulkan_readback_fnv1a64: 0, - vulkan_portability_enumeration: false, - vulkan_portability_subset_enabled: false, - }; - serde_json::to_string_pretty(&smoke_report) - .map(|json| format!("{json}\n")) - .map_err(|err| format!("native smoke report serialization failed: {err}")) -} - -impl ApplicationHandler for SmokeApp { - fn new_events(&mut self, event_loop: &ActiveEventLoop, _cause: StartCause) { - let _ = self.abort_if_timed_out(event_loop); - } - - fn resumed(&mut self, event_loop: &ActiveEventLoop) { - if self.abort_if_timed_out(event_loop) { - return; - } - self.progress.window_phase.store(WindowPhase::Resumed); - if self.window.is_some() { - return; - } - let plan = match WinitWindowPlan::smoke().validate() { - Ok(plan) => plan, - Err(err) => { - self.error = Some(err.to_string()); - event_loop.exit(); - return; - } - }; - self.progress - .window_phase - .store(WindowPhase::CreatingWindow); - let attributes = Window::default_attributes() - .with_title("FParkan Vulkan smoke") - .with_inner_size(PhysicalSize::new(plan.width, plan.height)); - let window = match event_loop.create_window(attributes) { - Ok(window) => window, - Err(err) => { - self.error = Some(format!("winit window: {err}")); - event_loop.exit(); - return; - } - }; - let Some(native_handles) = window_native_handles(&window) else { - self.error = Some("winit window does not expose native handles".to_string()); - event_loop.exit(); - return; - }; - let size = window.inner_size(); - let renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo { - application_name: "fparkan-vulkan-smoke".to_string(), - native_handles, - drawable_extent: (size.width.max(1), size.height.max(1)), - render_request: RenderRequest::conservative(), - enable_validation: true, - mesh: self.mesh.clone(), - camera: VulkanStaticCamera::default(), - materials: self - .materials - .iter() - .map(|material| VulkanStaticMaterial { - material_index: material.material_index, - texture: material.texture.clone(), - }) - .collect(), - bootstrap_progress: Some(Arc::clone(&self.progress.bootstrap)), - }) { - Ok(renderer) => renderer, - Err(err) => { - self.error = Some(err.to_string()); - event_loop.exit(); - return; - } - }; - self.last_size = Some((size.width, size.height)); - self.window_id = Some(window.id()); - self.progress.window_phase.store(WindowPhase::Created); - self.renderer = Some(renderer); - self.window = Some(window); - self.schedule_next_redraw(); - } - - fn window_event( - &mut self, - event_loop: &ActiveEventLoop, - window_id: WindowId, - event: WindowEvent, - ) { - if self.abort_if_timed_out(event_loop) { - return; - } - if Some(window_id) != self.window_id { - return; - } - match event { - WindowEvent::CloseRequested => { - if self.output.is_none() { - self.error = Some("native smoke window closed before completion".to_string()); - } - event_loop.exit(); - } - WindowEvent::Resized(size) => { - if self - .last_size - .is_some_and(|last| last != (size.width, size.height)) - { - self.resize_count = self.resize_count.saturating_add(1); - self.progress - .resize_count - .store(self.resize_count, Ordering::SeqCst); - } - self.last_size = Some((size.width, size.height)); - if let Some(renderer) = self.renderer.as_mut() { - renderer.request_resize((size.width, size.height)); - } - } - WindowEvent::RedrawRequested => { - let Some(renderer) = self.renderer.as_mut() else { - self.error = Some("native smoke renderer was not initialized".to_string()); - event_loop.exit(); - return; - }; - if let Err(error) = renderer.set_camera(VulkanStaticCamera::default()) { - self.error = Some(error.to_string()); - event_loop.exit(); - return; - } - match renderer.draw_frame() { - Ok(VulkanSmokeFrameOutcome::Presented) => { - self.frames_presented = self.frames_presented.saturating_add(1); - self.progress - .frames_presented - .store(self.frames_presented, Ordering::SeqCst); - } - Ok( - VulkanSmokeFrameOutcome::Recreated | VulkanSmokeFrameOutcome::ZeroExtent, - ) => {} - Err(err) => { - self.error = Some(err.to_string()); - event_loop.exit(); - return; - } - } - let recreate_count = renderer.swapchain_recreate_count(); - self.progress - .swapchain_recreate_count - .store(recreate_count, Ordering::SeqCst); - let should_request_resize = - !self.resize_requested && self.frames_presented >= self.options.resize_frame; - let should_complete = self.frames_presented >= self.options.frames - && self.resize_count > 0 - && recreate_count > 0; - let _ = renderer; - if should_request_resize { - self.request_controlled_resize(); - } - if should_complete { - self.complete(event_loop); - } else { - self.schedule_next_redraw(); - } - } - _ => {} - } - } - - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - if self.abort_if_timed_out(event_loop) { - return; - } - if self.output.is_none() && self.error.is_none() { - self.schedule_next_redraw(); - } - } -} - -#[derive(Serialize)] -struct SmokeReport<'a> { - schema_version: &'static str, - commit_sha: String, - git_dirty: bool, - runner_identity: String, - runner_architecture: &'static str, - rust_toolchain: String, - target_triple: String, - platform: &'static str, - status: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - failure_reason: Option<&'a str>, - frames: u32, - resize_count: u32, - swapchain_recreate_count: u32, - validation_warning_count: u32, - validation_error_count: u32, - validation_vuids: &'a [String], - requested_frames: u32, - timeout_seconds: u64, - mesh_source: &'a str, - mesh_archive: &'a str, - mesh_name: &'a str, - mesh_vertex_count: usize, - mesh_index_count: usize, - mesh_draw_count: usize, - texture_source: &'a str, - texture_archive: &'a str, - texture_name: &'a str, - texture_width: u32, - texture_height: u32, - material_binding_count: usize, - mesh_material_indices: Vec<u16>, - material_texture_names: Vec<String>, - shader_manifest_hash: &'a str, - vulkan_loader_status: &'a str, - vulkan_instance_status: &'a str, - window_status: &'a str, - vulkan_surface_status: &'a str, - vulkan_device_status: &'a str, - vulkan_device_name: &'a str, - vulkan_logical_device_status: &'a str, - vulkan_logical_device_graphics_queue_family: u32, - vulkan_logical_device_present_queue_family: u32, - vulkan_logical_device_enabled_extension_count: u32, - vulkan_swapchain_status: &'a str, - vulkan_swapchain_width: u32, - vulkan_swapchain_height: u32, - vulkan_swapchain_image_count: u32, - vulkan_swapchain_image_format: i32, - vulkan_swapchain_image_usage: u32, - vulkan_readback_copy_count: u64, - vulkan_readback_byte_count: u64, - vulkan_readback_fnv1a64: u64, - vulkan_portability_enumeration: bool, - vulkan_portability_subset_enabled: bool, -} - -#[derive(Debug, Default)] -struct SharedSmokeProgress { - bootstrap: Arc<VulkanSmokeBootstrapProgress>, - window_phase: AtomicWindowPhase, - frames_presented: AtomicU32, - resize_count: AtomicU32, - swapchain_recreate_count: AtomicU32, -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -enum WindowPhase { - #[default] - NotStarted, - Resumed, - CreatingWindow, - Created, -} - -impl WindowPhase { - const fn as_u8(self) -> u8 { - match self { - Self::NotStarted => 0, - Self::Resumed => 1, - Self::CreatingWindow => 2, - Self::Created => 3, - } - } - - const fn from_u8(value: u8) -> Self { - match value { - 1 => Self::Resumed, - 2 => Self::CreatingWindow, - 3 => Self::Created, - _ => Self::NotStarted, - } - } - - const fn status(self) -> &'static str { - match self { - Self::NotStarted => "failed", - Self::Resumed => "resumed", - Self::CreatingWindow => "creating", - Self::Created => "created", - } - } -} - -#[derive(Debug, Default)] -struct AtomicWindowPhase(AtomicU32); - -impl AtomicWindowPhase { - fn store(&self, phase: WindowPhase) { - self.0.store(u32::from(phase.as_u8()), Ordering::SeqCst); - } - - fn status(&self) -> &'static str { - match u8::try_from(self.0.load(Ordering::SeqCst)) { - Ok(value) => WindowPhase::from_u8(value), - Err(_) => WindowPhase::NotStarted, - } - .status() - } -} - -fn actual_platform() -> &'static str { - match std::env::consts::OS { - "windows" => "windows", - other => other, - } -} - -fn actual_architecture() -> &'static str { - match std::env::consts::ARCH { - "arm64" => "aarch64", - other => other, - } -} - -fn compiled_commit_sha() -> String { - option_env!("FPARKAN_BUILD_COMMIT_SHA") - .filter(|value| is_commit_sha(value)) - .map_or_else(runtime_git_commit_sha, ToString::to_string) -} - -fn runtime_git_commit_sha() -> String { - Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .map(|value| value.trim().to_string()) - .filter(|value| is_commit_sha(value)) - .unwrap_or_else(|| "unknown".to_string()) -} - -fn compiled_git_dirty() -> bool { - option_env!("FPARKAN_BUILD_GIT_DIRTY") - .and_then(parse_bool_env) - .unwrap_or_else(runtime_git_dirty) -} - -fn runtime_git_dirty() -> bool { - Command::new("git") - .args(["status", "--short"]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .is_some_and(|output| !output.trim().is_empty()) -} - -fn compiled_rust_toolchain() -> String { - option_env!("FPARKAN_BUILD_RUST_TOOLCHAIN") - .filter(|value| !value.trim().is_empty()) - .map_or_else(current_rustc_release, ToString::to_string) -} - -fn current_rustc_release() -> String { - Command::new("rustc") - .arg("-Vv") - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .and_then(|output| { - output - .lines() - .find_map(|line| line.strip_prefix("release: ").map(ToString::to_string)) - }) - .unwrap_or_else(|| "unknown".to_string()) -} - -fn compiled_target_triple() -> String { - option_env!("FPARKAN_BUILD_TARGET_TRIPLE") - .filter(|value| !value.trim().is_empty()) - .map_or_else(current_rustc_host_triple, ToString::to_string) -} - -fn measured_runner_identity() -> String { - if std::env::var_os("GITHUB_ACTIONS").is_some() { - let run_id = std::env::var("GITHUB_RUN_ID").unwrap_or_else(|_| "unknown-run".to_string()); - let job = std::env::var("GITHUB_JOB").unwrap_or_else(|_| "unknown-job".to_string()); - format!("github-actions/{run_id}/{job}") - } else if std::env::var_os("CI").is_some() { - let job = std::env::var("CI_JOB_NAME") - .or_else(|_| std::env::var("BUILD_ID")) - .unwrap_or_else(|_| "generic-ci".to_string()); - format!("ci/{job}") - } else { - format!("local/{}", std::env::consts::OS) - } -} - -fn current_rustc_host_triple() -> String { - Command::new("rustc") - .arg("-Vv") - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .and_then(|output| { - output - .lines() - .find_map(|line| line.strip_prefix("host: ").map(ToString::to_string)) - }) - .unwrap_or_else(|| "unknown".to_string()) -} - -fn is_commit_sha(value: &str) -> bool { - value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()) -} - -fn parse_bool_env(value: &str) -> Option<bool> { - match value { - "true" => Some(true), - "false" => Some(false), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::cell::RefCell; - use std::rc::Rc; - - #[derive(Clone, Copy, Debug, Eq, PartialEq)] - enum DropStep { - Renderer, - Window, - } - - struct DropTracker { - step: DropStep, - log: Rc<RefCell<Vec<DropStep>>>, - } - - impl Drop for DropTracker { - fn drop(&mut self) { - self.log.borrow_mut().push(self.step); - } - } - - fn tracker(step: DropStep, log: &Rc<RefCell<Vec<DropStep>>>) -> DropTracker { - DropTracker { - step, - log: Rc::clone(log), - } - } - - #[test] - fn parses_required_args() { - let parsed = SmokeOptions::parse(&["--out".to_string(), "target/report.json".to_string()]); - - assert_eq!( - parsed, - Ok(SmokeOptions { - out: PathBuf::from("target/report.json"), - frames: DEFAULT_TARGET_FRAMES, - resize_frame: DEFAULT_RESIZE_FRAME, - timeout_seconds: DEFAULT_TIMEOUT_SECONDS, - expected_readback: None, - mesh_input: MeshInput::SmokeTriangle, - texture_input: None, - }) - ); - } - - #[test] - fn rejects_too_few_frames() { - let parsed = SmokeOptions::parse(&[ - "--out".to_string(), - "target/report.json".to_string(), - "--frames".to_string(), - "299".to_string(), - ]); - - assert_eq!( - parsed, - Err("native smoke requires --frames >= 300".to_string()) - ); - } - - #[test] - fn parses_timeout_seconds() { - let parsed = SmokeOptions::parse(&[ - "--out".to_string(), - "target/report.json".to_string(), - "--timeout-seconds".to_string(), - "45".to_string(), - ]); - - assert_eq!( - parsed, - Ok(SmokeOptions { - out: PathBuf::from("target/report.json"), - frames: DEFAULT_TARGET_FRAMES, - resize_frame: DEFAULT_RESIZE_FRAME, - timeout_seconds: 45, - expected_readback: None, - mesh_input: MeshInput::SmokeTriangle, - texture_input: None, - }) - ); - } - - #[test] - fn rejects_zero_timeout_seconds() { - let parsed = SmokeOptions::parse(&[ - "--out".to_string(), - "target/report.json".to_string(), - "--timeout-seconds".to_string(), - "0".to_string(), - ]); - - assert_eq!( - parsed, - Err("native smoke requires --timeout-seconds >= 1".to_string()) - ); - } - - #[test] - fn parses_original_msh_input_as_one_atomic_request() { - let parsed = SmokeOptions::parse(&[ - "--out".to_string(), - "target/report.json".to_string(), - "--model-root".to_string(), - "C:/GOG".to_string(), - "--model-archive".to_string(), - "system.rlb".to_string(), - "--model-name".to_string(), - "MTCHECK.MSH".to_string(), - ]); - - assert!(matches!( - parsed, - Ok(SmokeOptions { - mesh_input: MeshInput::Msh { archive, name, .. }, - .. - }) if archive == "system.rlb" && name == "MTCHECK.MSH" - )); - } - - #[test] - fn parses_original_land_msh_as_direct_terrain_input() { - let parsed = SmokeOptions::parse(&[ - "--out".to_string(), - "target/report.json".to_string(), - "--terrain-path".to_string(), - "C:/GOG/DATA/MAPS/11/Land.msh".to_string(), - ]); - - assert!(matches!( - parsed, - Ok(SmokeOptions { - mesh_input: MeshInput::LandMsh { path }, - .. - }) if path == std::path::Path::new("C:/GOG/DATA/MAPS/11/Land.msh") - )); - } - - #[test] - fn rejects_partial_original_msh_input() { - let parsed = SmokeOptions::parse(&[ - "--out".to_string(), - "target/report.json".to_string(), - "--model-root".to_string(), - "C:/GOG".to_string(), - ]); - - assert_eq!( - parsed, - Err( - "--model-root, --model-archive and --model-name must be supplied together" - .to_string() - ) - ); - } - - #[test] - fn parses_original_texm_input_as_one_atomic_request() { - let parsed = SmokeOptions::parse(&[ - "--out".to_string(), - "target/report.json".to_string(), - "--texture-root".to_string(), - "C:/GOG".to_string(), - "--texture-archive".to_string(), - "Textures.lib".to_string(), - "--texture-name".to_string(), - "DEFAULT.0".to_string(), - ]); - - assert!(matches!( - parsed, - Ok(SmokeOptions { - texture_input: Some(TextureInput::Direct(ResourceInput { archive, name, .. })), - .. - }) if archive == "Textures.lib" && name == "DEFAULT.0" - )); - } - - #[test] - fn parses_original_wear_material_input_as_one_atomic_request() { - let parsed = SmokeOptions::parse(&[ - "--out".to_string(), - "target/report.json".to_string(), - "--wear-root".to_string(), - "C:/GOG".to_string(), - "--wear-archive".to_string(), - "system.rlb".to_string(), - "--wear-name".to_string(), - "MODEL.WEA".to_string(), - "--material-index".to_string(), - "7".to_string(), - ]); - - assert!(matches!( - parsed, - Ok(SmokeOptions { - texture_input: Some(TextureInput::WearMaterial(WearMaterialInput { - archive, - name, - material_index, - .. - })), - .. - }) if archive == "system.rlb" && name == "MODEL.WEA" && material_index == 7 - )); - } - - #[test] - fn parses_original_wear_batch_material_input_without_override() { - let parsed = SmokeOptions::parse(&[ - "--out".to_string(), - "target/report.json".to_string(), - "--wear-root".to_string(), - "C:/GOG".to_string(), - "--wear-archive".to_string(), - "system.rlb".to_string(), - "--wear-name".to_string(), - "MODEL.WEA".to_string(), - ]); - - assert!(matches!( - parsed, - Ok(SmokeOptions { - texture_input: Some(TextureInput::WearBatchMaterials(WearInput { archive, name, .. })), - .. - }) if archive == "system.rlb" && name == "MODEL.WEA" - )); - } - - #[test] - fn rejects_deprecated_self_assertion_flags() { - for flag in [ - "--status", - "--platform", - "--validation-error-count", - "--resize-count", - "--swapchain-recreate-count", - ] { - let parsed = SmokeOptions::parse(&[ - "--out".to_string(), - "target/report.json".to_string(), - flag.to_string(), - "value".to_string(), - ]); - - assert_eq!(parsed, Err(format!("unknown native smoke option: {flag}"))); - } - } - - #[test] - fn commit_sha_validation_accepts_hex_head() { - assert!(is_commit_sha("0123456789abcdef0123456789abcdef01234567")); - } - - #[test] - fn commit_sha_validation_rejects_non_hex_or_wrong_length() { - assert!(!is_commit_sha("0123456789abcdef0123456789abcdef0123456")); - assert!(!is_commit_sha("zz23456789abcdef0123456789abcdef01234567")); - } - - #[test] - fn parses_bool_env_values() { - assert_eq!(parse_bool_env("true"), Some(true)); - assert_eq!(parse_bool_env("false"), Some(false)); - assert_eq!(parse_bool_env("1"), None); - } - - #[test] - fn smoke_report_json_contains_expected_fields() { - let json = serde_json::to_string_pretty(&SmokeReport { - schema_version: SCHEMA_VERSION, - commit_sha: "0123456789abcdef0123456789abcdef01234567".to_string(), - git_dirty: false, - runner_identity: "windows-local/stage0".to_string(), - runner_architecture: "x86_64", - rust_toolchain: "1.97.1".to_string(), - target_triple: "x86_64-pc-windows-msvc".to_string(), - platform: "windows", - status: "passed", - failure_reason: None, - frames: 300, - resize_count: 1, - swapchain_recreate_count: 1, - validation_warning_count: 0, - validation_error_count: 0, - validation_vuids: &["VUID-A".to_string(), "VUID-B".to_string()], - requested_frames: 300, - timeout_seconds: DEFAULT_TIMEOUT_SECONDS, - mesh_source: "original-msh", - mesh_archive: "system.rlb", - mesh_name: "MTCHECK.MSH", - mesh_vertex_count: 128, - mesh_index_count: 252, - mesh_draw_count: 1, - texture_source: "original-texm", - texture_archive: "Textures.lib", - texture_name: "DEFAULT.0", - texture_width: 16, - texture_height: 16, - material_binding_count: 1, - mesh_material_indices: vec![0], - material_texture_names: vec!["DEFAULT.0".to_string()], - shader_manifest_hash: "deadbeef", - vulkan_loader_status: "available", - vulkan_instance_status: "created", - window_status: "created", - vulkan_surface_status: "created", - vulkan_device_status: "selected", - vulkan_device_name: "Windows test GPU", - vulkan_logical_device_status: "created", - vulkan_logical_device_graphics_queue_family: 0, - vulkan_logical_device_present_queue_family: 0, - vulkan_logical_device_enabled_extension_count: 2, - vulkan_swapchain_status: "created", - vulkan_swapchain_width: 960, - vulkan_swapchain_height: 540, - vulkan_swapchain_image_count: 3, - vulkan_swapchain_image_format: 50, - vulkan_swapchain_image_usage: 17, - vulkan_readback_copy_count: 300, - vulkan_readback_byte_count: 4_147_200, - vulkan_readback_fnv1a64: 0x1234_5678_90ab_cdef, - vulkan_portability_enumeration: false, - vulkan_portability_subset_enabled: false, - }) - .expect("smoke report should serialize"); - - assert!(json.contains("\"schema_version\": \"fparkan-native-smoke-v1\"")); - assert!(json.contains("\"validation_vuids\": [")); - assert!(json.contains("\"vulkan_device_name\": \"Windows test GPU\"")); - assert!(json.contains("\"runner_architecture\": \"x86_64\"")); - assert!(json.contains("\"mesh_source\": \"original-msh\"")); - assert!(json.contains("\"mesh_draw_count\": 1")); - assert!(json.contains("\"texture_source\": \"original-texm\"")); - assert!(json.contains("\"vulkan_readback_copy_count\": 300")); - assert!(json.contains("\"vulkan_readback_byte_count\": 4147200")); - } - - #[test] - fn finish_writes_failure_artifact() { - let root = std::env::temp_dir().join(format!( - "fparkan-native-smoke-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("time") - .as_nanos() - )); - std::fs::create_dir_all(&root).expect("temp dir"); - let out = root.join("report.json"); - - let result = SmokeApp { - options: SmokeOptions { - out: out.clone(), - frames: DEFAULT_TARGET_FRAMES, - resize_frame: DEFAULT_RESIZE_FRAME, - timeout_seconds: 7, - expected_readback: None, - mesh_input: MeshInput::SmokeTriangle, - texture_input: None, - }, - mesh: VulkanStaticMesh::smoke_triangle(), - materials: Vec::new(), - completed: Arc::new(AtomicBool::new(false)), - progress: Arc::new(SharedSmokeProgress::default()), - window_id: None, - window: None, - renderer: None, - final_renderer: None, - error: Some("native smoke timed out after 7 seconds".to_string()), - output: None, - frames_presented: 42, - resize_count: 0, - resize_requested: false, - last_size: None, - started_at: Instant::now(), - } - .finish(); - - assert_eq!( - result, - Err("native smoke timed out after 7 seconds".to_string()) - ); - - let json = std::fs::read_to_string(&out).expect("failure report"); - assert!(json.contains("\"status\": \"failed\"")); - assert!(json.contains("\"failure_reason\": \"native smoke timed out after 7 seconds\"")); - assert!(json.contains("\"timeout_seconds\": 7")); - - std::fs::remove_file(out).expect("cleanup report"); - std::fs::remove_dir(root).expect("cleanup dir"); - } - - #[test] - fn readback_comparator_accepts_exact_bytes_and_reports_first_difference() { - let root = std::env::temp_dir().join(format!("fparkan-readback-{}", std::process::id())); - std::fs::create_dir_all(&root).expect("temp dir"); - let expected = root.join("expected.readback-vkformat-50.raw"); - let actual = root.join("actual.readback-vkformat-50.raw"); - std::fs::write(&expected, [1_u8, 2, 3]).expect("expected"); - std::fs::write(&actual, [1_u8, 2, 3]).expect("actual"); - assert_eq!(compare_readback_files(&expected, &actual), Ok(())); - std::fs::write(&actual, [1_u8, 9, 3]).expect("different actual"); - assert_eq!( - compare_readback_files(&expected, &actual), - Err("pixel readback mismatch at byte 1: expected=2 actual=9".to_string()) - ); - let incompatible = root.join("incompatible.readback-vkformat-37.raw"); - std::fs::write(&incompatible, [1_u8, 2, 3]).expect("incompatible actual"); - assert_eq!( - compare_readback_files(&expected, &incompatible), - Err("pixel readback format mismatch: expected=50 actual=37".to_string()) - ); - std::fs::remove_dir_all(root).expect("cleanup"); - } - - #[test] - fn renderer_is_dropped_before_window() { - let log = Rc::new(RefCell::new(Vec::new())); - let mut renderer = Some(tracker(DropStep::Renderer, &log)); - let mut window = Some(tracker(DropStep::Window, &log)); - - drop_renderer_before_window(&mut renderer, &mut window); - - assert!(renderer.is_none()); - assert!(window.is_none()); - assert_eq!( - Rc::into_inner(log).expect("trackers released").into_inner(), - vec![DropStep::Renderer, DropStep::Window] - ); - } -} |
