aboutsummaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/fparkan-cli/Cargo.toml22
-rw-r--r--apps/fparkan-cli/src/main.rs1070
-rw-r--r--apps/fparkan-game/Cargo.toml22
-rw-r--r--apps/fparkan-game/src/main.rs1041
-rw-r--r--apps/fparkan-headless/Cargo.toml14
-rw-r--r--apps/fparkan-headless/src/main.rs267
-rw-r--r--apps/fparkan-viewer/Cargo.toml13
-rw-r--r--apps/fparkan-viewer/src/main.rs350
-rw-r--r--apps/fparkan-vulkan-smoke/Cargo.toml19
-rw-r--r--apps/fparkan-vulkan-smoke/build.rs134
-rw-r--r--apps/fparkan-vulkan-smoke/src/main.rs1973
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]
- );
- }
-}