diff options
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] - ); - } -} |
