From e9c1a83c51070c4f8da76540c17c2393922b6fcc Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 17:39:52 +0400 Subject: [PATCH] fix(terrain): preserve opaque shade lookup keys --- apps/fparkan-cli/src/main.rs | 95 ++++++++++++++++++------ crates/fparkan-inspection/src/lib.rs | 41 ++++++++++ crates/fparkan-terrain-format/src/lib.rs | 24 +++--- crates/fparkan-terrain/src/lib.rs | 4 +- docs/tomes/05-render.md | 13 ++-- 5 files changed, 131 insertions(+), 46 deletions(-) diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs index e469509..d951c5e 100644 --- a/apps/fparkan-cli/src/main.rs +++ b/apps/fparkan-cli/src/main.rs @@ -27,7 +27,7 @@ use fparkan_assets::{ use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions}; use fparkan_inspection::{ inspect_archive_file, inspect_land_msh_bounds_file, inspect_model_from_root, - load_land_msh_from_path, ArchiveInspection, ModelInspection, + inspect_wear_from_root, load_land_msh_from_path, ArchiveInspection, ModelInspection, }; use fparkan_path::{normalize_relative, PathPolicy}; use fparkan_prototype::build_prototype_graph_report; @@ -46,6 +46,7 @@ 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"; #[derive(Serialize)] struct ArchiveInspectOutput<'a> { @@ -128,11 +129,11 @@ struct TerrainInspectOutput { faces: usize, slots: usize, material_tags: Vec, - shade_material_pairs: usize, + shade_pairs: usize, #[serde(skip_serializing_if = "Option::is_none")] - shade_material_lookup_min: Option, + shade_lookup_key_min: Option, #[serde(skip_serializing_if = "Option::is_none")] - shade_material_lookup_max: Option, + shade_lookup_key_max: Option, shade_batch_boundaries: usize, } @@ -142,6 +143,19 @@ struct TerrainMaterialTagCount { 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, + #[serde(skip_serializing_if = "Option::is_none")] + last_material: Option, +} + #[derive(Serialize)] struct ModelInspectOutput<'a> { schema_version: &'static str, @@ -238,6 +252,10 @@ fn run(args: &[String]) -> Result<(), String> { 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) @@ -479,19 +497,13 @@ fn inspect_terrain(args: &[String]) -> Result<(), String> { .map(|(tag, faces)| TerrainMaterialTagCount { tag, faces }) .collect::>(); material_tags.sort_unstable_by_key(|entry| entry.tag); - let shade_material_pairs = (0..terrain.slots.slots_raw.len()) + let shade_pairs = (0..terrain.slots.slots_raw.len()) .filter_map(|slot_index| terrain.slot_material_pairs(slot_index)) .flatten() .collect::>(); - let shade_material_lookup_min = shade_material_pairs - .iter() - .map(|pair| pair.shade_selection().material_index) - .min(); - let shade_material_lookup_max = shade_material_pairs - .iter() - .map(|pair| pair.shade_selection().material_index) - .max(); - let shade_batch_boundaries = shade_material_pairs + 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(); @@ -506,15 +518,35 @@ fn inspect_terrain(args: &[String]) -> Result<(), String> { faces: terrain.faces.len(), slots: terrain.slots.slots_raw.len(), material_tags, - shade_material_pairs: shade_material_pairs.len(), - shade_material_lookup_min, - shade_material_lookup_max, + 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")?; @@ -632,7 +664,7 @@ fn prototype_graph_requiredness_label( } fn usage() -> String { - "usage: fparkan corpus discover|validate --root [--format json] | archive inspect [--format json] | terrain inspect [--format json] | model inspect --root --archive --resource [--format json] | prototype inspect --root --key [--format json] | mission graph|inspect --root --mission [--format json]".to_string() + "usage: fparkan corpus discover|validate --root [--format json] | archive inspect [--format json] | terrain inspect [--format json] | wear inspect --root --archive --resource [--format json] | model inspect --root --archive --resource [--format json] | prototype inspect --root --key [--format json] | mission graph|inspect --root --mission [--format json]".to_string() } #[cfg(test)] @@ -759,16 +791,35 @@ mod tests { TerrainMaterialTagCount { tag: 0, faces: 1 }, TerrainMaterialTagCount { tag: 3, faces: 1 }, ], - shade_material_pairs: 2, - shade_material_lookup_min: Some(1), - shade_material_lookup_max: Some(2), + 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_material_pairs\":2,\"shade_material_lookup_min\":1,\"shade_material_lookup_max\":2,\"shade_batch_boundaries\":1}" + "{\"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/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs index e2b9068..be1f098 100644 --- a/crates/fparkan-inspection/src/lib.rs +++ b/crates/fparkan-inspection/src/lib.rs @@ -146,6 +146,19 @@ pub struct WearMaterialTexture { pub image: fparkan_texm::RgbaImage, } +/// Compact inspection summary of a WEAR resource stored in an archive. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WearInspection { + /// Number of material rows. + pub materials: usize, + /// Number of lightmap rows. + pub lightmaps: usize, + /// First material resource name, when present. + pub first_material: Option, + /// Last material resource name, when present. + pub last_material: Option, +} + /// Land map/msh inspection payload. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MapInspection { @@ -374,6 +387,34 @@ pub fn inspect_model_from_root( }) } +/// Inspects a WEAR resource through repository-backed lookup. +/// +/// # Errors +/// +/// Returns a string error when the resource cannot be resolved or parsed as a +/// valid WEAR payload. +pub fn inspect_wear_from_root( + root: &Path, + archive: &str, + resource: &str, +) -> Result { + let bytes = read_resource_bytes_diagnostic(root, archive, resource) + .map_err(|err| render_human(&err))?; + let wear = decode_wear(&bytes).map_err(|err| err.to_string())?; + Ok(WearInspection { + materials: wear.entries.len(), + lightmaps: wear.lightmaps.len(), + first_material: wear + .entries + .first() + .map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()), + last_material: wear + .entries + .last() + .map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()), + }) +} + /// Loads and validates a model resource through repository-backed lookup. /// /// # Errors diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs index 764de09..54bd3f4 100644 --- a/crates/fparkan-terrain-format/src/lib.rs +++ b/crates/fparkan-terrain-format/src/lib.rs @@ -212,18 +212,15 @@ pub struct TerrainMaterialPair { } impl TerrainMaterialPair { - /// Returns the material-manager selection used by `GetShade`. + /// Returns the opaque lookup key supplied to `GetShade`. /// - /// `GetShade` owns a manager loaded only with `Shade.wea`, so its - /// 16-bit lookup occupies row bits of that manager's table zero. It is - /// deliberately separate from the `Land1.wea`/`Land2.wea` selections - /// carried by [`TerrainMaterialLayers`]. + /// This is deliberately separate from the `Land1.wea`/`Land2.wea` + /// selectors carried by [`TerrainMaterialLayers`]. Although `GetShade` + /// owns a manager loaded from `Shade.wea`, this key is not a WEAR row: + /// observed map values exceed that table's row count. #[must_use] - pub const fn shade_selection(self) -> TerrainMaterialSelection { - TerrainMaterialSelection { - table_index: 0, - material_index: self.material_lookup, - } + pub const fn shade_lookup_key(self) -> u16 { + self.material_lookup } } @@ -1586,11 +1583,8 @@ mod tests { ]) ); assert_eq!( - document.slot_material_pairs(0).expect("material pairs")[0].shade_selection(), - TerrainMaterialSelection { - table_index: 0, - material_index: 0x0102, - } + document.slot_material_pairs(0).expect("material pairs")[0].shade_lookup_key(), + 0x0102 ); assert_eq!(document.slot_material_pairs(1), None); diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index a69df2a..4eef871 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -27,9 +27,7 @@ use std::collections::VecDeque; /// /// Applications access this semantic terrain API through this terrain crate /// rather than taking a direct dependency on the binary-format parser. -pub use fparkan_terrain_format::{ - TerrainMaterialLayers, TerrainMaterialPair, TerrainMaterialSelection, -}; +pub use fparkan_terrain_format::{TerrainMaterialLayers, TerrainMaterialSelection}; /// Terrain world. #[derive(Clone, Debug, Default)] diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index f6f7832..7a6944f 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1586,12 +1586,13 @@ fields, while `LandMeshDocument::slot_material_pairs` decodes the selected type-11 entries as `{ material_lookup: u16, flags: u16 }`. Flag `0x0010` is a proven batch boundary in `GetShade`. Crucially, this lookup does **not** select either map-local Land table: the `GetShade` constructor loads its own one-table -manager from `system.rlb` resource `Shade.wea`, so the 16-bit value is that -manager's table-zero row. `TerrainMaterialPair::shade_selection` records this -separate selector instead of conflating it with `Land1.wea`/`Land2.wea`. The -AutoDemo inspector now reports 128 slots, 3,174 addressed shade pairs, selector -range `0..3173`, and 392 `0x0010` batch boundaries. The meanings of remaining -flags and the final blend operation remain unassigned. +manager from `system.rlb` resource `Shade.wea`. That WEAR table has one row, +`LIGHT1`, whereas AutoDemo's 3,174 pairs use keys `0..3173`; therefore the +16-bit value is an opaque manager lookup/cache key, **not** a table-row +selector. `TerrainMaterialPair::shade_lookup_key` records this separate key +without conflating it with `Land1.wea`/`Land2.wea`. The AutoDemo inspector now +reports 128 slots and 392 `0x0010` batch boundaries. The meanings of remaining +flags, this cache mapping, and the final blend operation remain unassigned. The static Vulkan bridge now carries each contiguous face run as a separate draw range keyed by the original packed `material_tag`; it neither reorders