From 6a0cf0c37465e8ffaa08c75a5912f599309c5050 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 18 Oct 2026 11:38:50 +0400 Subject: feat(assets): load native BAS geometry for placements --- crates/fparkan-assets/src/lib.rs | 149 ++++++++++- crates/fparkan-prototype/src/lib.rs | 487 ++++++++++++++++++++++++++++++++++-- crates/fparkan-runtime/src/lib.rs | 110 +++++++- 3 files changed, 721 insertions(+), 25 deletions(-) diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 08822cd..4d7be69 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -16,9 +16,9 @@ use fparkan_nres::{decode as decode_nres, ReadProfile}; pub use fparkan_nres::{NresDocument, NresError}; use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy, ResourceName}; use fparkan_prototype::{ - EffectivePrototype, PrototypeGeometry, PrototypeGraph, PrototypeGraphEdge, - PrototypeGraphFailure, PrototypeGraphNodeKind, PrototypeGraphProvenance, PrototypeGraphReport, - PrototypeGraphRequiredness, + decode_bas_geometry, BasGeometry, EffectivePrototype, PrototypeGeometry, PrototypeGraph, + PrototypeGraphEdge, PrototypeGraphFailure, PrototypeGraphNodeKind, PrototypeGraphProvenance, + PrototypeGraphReport, PrototypeGraphRequiredness, }; use fparkan_resource::{CachedResourceRepository, ResourceError, ResourceKey, ResourceRepository}; pub use fparkan_terrain::{TerrainError, TerrainWorld}; @@ -918,6 +918,17 @@ pub struct PreparedComponentControl { pub point_list: Option, } +/// Component-local BAS geometry loaded from an explicitly referenced BAS +/// resource. This remains aligned with each component instance, not the shared +/// model cache. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedComponentBas { + /// BAS resource that supplied this geometry. + pub source: ResourceKey, + /// Decoded BAS point sections. + pub geometry: BasGeometry, +} + /// Raw and decoded fields from one 36-byte CTLD animation row. #[derive(Clone, Copy, Debug, PartialEq)] pub struct PreparedControlTimelineRow { @@ -1570,6 +1581,10 @@ pub struct MissionAssets { /// component list. `None` means no CTLD pose override is available because /// there is no explicit reference or its referenced archive/entry is absent. pub object_component_controls: Vec>>, + /// Optional component-local BAS geometry aligned with each object's visual + /// component list. `None` means there is no explicit BAS reference or its + /// archive/entry is absent. + pub object_component_bas: Vec>>, } impl MissionAssets { @@ -2007,7 +2022,15 @@ fn prepare_mission_assets_with_repository_internal( } let mut prototype_component_controls = Vec::with_capacity(prototypes.len()); + let mut prototype_component_bas = Vec::with_capacity(prototypes.len()); for (prototype_index, prototype) in prototypes.iter().enumerate() { + let bas = match prototype.bas_resource.clone() { + Some(source) => { + prepare_component_bas_from_resource(repository, source, prototype_index)? + } + None => None, + }; + prototype_component_bas.push(bas); let Some(source) = prototype.control_resource.clone() else { prototype_component_controls.push(None); continue; @@ -2040,6 +2063,7 @@ fn prepare_mission_assets_with_repository_internal( let mut object_visuals = Vec::with_capacity(root_prototype_spans.len()); let mut object_component_controls = Vec::with_capacity(root_prototype_spans.len()); + let mut object_component_bas = Vec::with_capacity(root_prototype_spans.len()); for (root_index, span) in root_prototype_spans.iter().enumerate() { if span.start > span.end || span.end > prototype_visual_ids.len() { return Err(AssetError::InvalidPrototype(format!( @@ -2048,6 +2072,7 @@ fn prepare_mission_assets_with_repository_internal( } let mut ids = Vec::new(); let mut controls = Vec::new(); + let mut bases = Vec::new(); for index in span.clone() { let visual_id = prototype_visual_ids[index]; // A unit DAT is an instance tree. Reusing one prepared visual @@ -2055,9 +2080,11 @@ fn prepare_mission_assets_with_repository_internal( // identical cannons on w_m_wlk2). ids.push(visual_id); controls.push(prototype_component_controls[index].clone()); + bases.push(prototype_component_bas[index].clone()); } object_visuals.push(ids); object_component_controls.push(controls); + object_component_bas.push(bases); } let assets = MissionAssets { @@ -2068,6 +2095,7 @@ fn prepare_mission_assets_with_repository_internal( visuals, object_visuals, object_component_controls, + object_component_bas, }; let report = AssetPreparationReport { model_count: assets.models.len(), @@ -3372,6 +3400,25 @@ fn prepare_component_control_from_resource( Ok(Some(control)) } +fn prepare_component_bas_from_resource( + repository: &R, + source: ResourceKey, + prototype_index: usize, +) -> Result, AssetError> { + let Some(payload) = read_optional_key(repository, &source, Some("component BAS geometry"))? + else { + return Ok(None); + }; + let geometry = decode_bas_geometry(&payload).map_err(|error| { + AssetError::InvalidPrototype(format!( + "prototype {prototype_index} BAS geometry {}:{}: {error}", + source.archive.as_str(), + String::from_utf8_lossy(&source.name.0), + )) + })?; + Ok(Some(PreparedComponentBas { source, geometry })) +} + fn decode_component_node_data_table( payload: &[u8], source: ResourceKey, @@ -4617,6 +4664,89 @@ mod tests { assert!(decode_component_point_list(&non_finite, source, 0).is_err()); } + #[test] + fn mission_assets_keep_explicit_bas_geometry_at_component_index() { + let mut bas = Vec::new(); + bas.extend_from_slice(&1_u32.to_le_bytes()); // first-section records + bas.extend_from_slice(&1_u32.to_le_bytes()); // declared count + for point in [[1.0_f32, 2.0, 3.0], [4.0, 5.0, 6.0]] { + for value in point { + bas.extend_from_slice(&value.to_le_bytes()); + } + } + bas.extend_from_slice(&0x1122_3344_u32.to_le_bytes()); + bas.extend_from_slice(&0x5566_7788_u32.to_le_bytes()); + bas.extend_from_slice(&1_u32.to_le_bytes()); // second-section records + bas.extend_from_slice(&1_u32.to_le_bytes()); + for point in [[7.0_f32, 8.0, 9.0], [10.0, 11.0, 12.0]] { + for value in point { + bas.extend_from_slice(&value.to_le_bytes()); + } + } + let repository = repository_with_archives(&[("fortif.rlb", &[(b"fr_b_plant.bas", &bas)])]); + let source = ResourceKey { + archive: parse_path("fortif.rlb").expect("archive"), + name: resource_name(b"fr_b_plant.bas"), + type_id: None, + }; + let without_bas = EffectivePrototype { + key: fparkan_prototype::PrototypeKey(resource_name(b"unrelated")), + geometry: PrototypeGeometry::NonGeometric, + source: fparkan_prototype::PrototypeSource::ObjectsRegistry, + source_type: None, + dependencies: Vec::new(), + control_resource: None, + point_resource: None, + node_data_resource: None, + bas_resource: None, + }; + let prototype = EffectivePrototype { + key: fparkan_prototype::PrototypeKey(resource_name(b"fr_b_plant")), + geometry: PrototypeGeometry::NonGeometric, + source: fparkan_prototype::PrototypeSource::ObjectsRegistry, + source_type: None, + dependencies: Vec::new(), + control_resource: None, + point_resource: None, + node_data_resource: None, + bas_resource: Some(source.clone()), + }; + + let assets = + prepare_mission_assets_with_repository(&repository, &[0..2], &[without_bas, prototype]) + .expect("mission BAS sidecar"); + assert_eq!(assets.object_component_bas[0].len(), 2); + assert!(assets.object_component_bas[0][0].is_none()); + let prepared = assets.object_component_bas[0][1] + .as_ref() + .expect("BAS aligned with component one"); + assert_eq!(prepared.source, source); + assert_eq!(prepared.geometry.first[0].points[1], [4.0, 5.0, 6.0]); + assert_eq!(prepared.geometry.first[0].opaque_words_a, [0x1122_3344]); + assert_eq!(prepared.geometry.second[0].points[1], [10.0, 11.0, 12.0]); + } + + #[test] + fn malformed_explicit_bas_geometry_is_reported_at_preparation_boundary() { + let bad_payload = u32::MAX.to_le_bytes(); + let repository = + repository_with_archives(&[("fortif.rlb", &[(b"broken.bas", &bad_payload)])]); + let source = ResourceKey { + archive: parse_path("fortif.rlb").expect("archive"), + name: resource_name(b"broken.bas"), + type_id: None, + }; + + let error = prepare_component_bas_from_resource(&repository, source, 3) + .expect_err("malformed BAS counts are rejected"); + assert!(matches!( + error, + AssetError::InvalidPrototype(message) + if message.contains("prototype 3 BAS geometry fortif.rlb:broken.bas") + && message.contains("limit") + )); + } + #[test] fn component_control_preserves_unresolved_point_offset_sentinels() { let mut control = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]); @@ -5007,6 +5137,7 @@ mod tests { control_resource: None, point_resource: None, node_data_resource: None, + bas_resource: None, }; let second = EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"mesh")), @@ -5022,6 +5153,7 @@ mod tests { control_resource: None, point_resource: None, node_data_resource: None, + bas_resource: None, }; assert_ne!(stable_visual_id(&first), stable_visual_id(&second)); @@ -5088,6 +5220,7 @@ mod tests { control_resource: None, point_resource: None, node_data_resource: None, + bas_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); let mut report = PrototypeGraphReport { @@ -5205,6 +5338,7 @@ mod tests { control_resource: None, point_resource: None, node_data_resource: None, + bas_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); graph @@ -5360,6 +5494,7 @@ mod tests { control_resource: None, point_resource: None, node_data_resource: None, + bas_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); let mut report = PrototypeGraphReport { @@ -5520,6 +5655,7 @@ mod tests { type_id: None, }), node_data_resource: None, + bas_resource: None, }, EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"tree-b")), @@ -5534,6 +5670,7 @@ mod tests { }), point_resource: None, node_data_resource: None, + bas_resource: None, }, EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"tree-c")), @@ -5548,6 +5685,7 @@ mod tests { }), point_resource: None, node_data_resource: None, + bas_resource: None, }, ]; @@ -5643,6 +5781,7 @@ mod tests { }), point_resource: None, node_data_resource: None, + bas_resource: None, }; let assets = prepare_mission_assets_with_repository(&repository, &[0..1], &[prototype]) @@ -5840,6 +5979,7 @@ mod tests { control_resource: None, point_resource: None, node_data_resource: None, + bas_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); let mut report = PrototypeGraphReport { @@ -6051,6 +6191,7 @@ mod tests { control_resource: None, point_resource: None, node_data_resource: None, + bas_resource: None, }, EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"tree_b")), @@ -6065,6 +6206,7 @@ mod tests { control_resource: None, point_resource: None, node_data_resource: None, + bas_resource: None, }, ]; (repo, prototypes) @@ -6227,6 +6369,7 @@ mod tests { prototype_requests: vec![prototype.key.clone()], root_prototype_request_spans: std::iter::once(0..1).collect(), root_unit_components: vec![Vec::new()], + root_unit_dat_header_flags: vec![None], visual_dependencies_expanded: false, nodes: vec![root_node, prototype_node, mesh_node], edges: vec![ diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs index 8bac8d9..beb8526 100644 --- a/crates/fparkan-prototype/src/lib.rs +++ b/crates/fparkan-prototype/src/lib.rs @@ -2,7 +2,7 @@ //! Prototype registry and unit DAT primitives. use encoding_rs::WINDOWS_1251; -use fparkan_binary::{checked_count_bytes, Cursor, DecodeError}; +use fparkan_binary::{checked_allocation_len, checked_count_bytes, Cursor, DecodeError, Limits}; use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName}; use fparkan_resource::{ archive_path, resource_name, ResourceError, ResourceKey, ResourceRepository, @@ -18,6 +18,9 @@ const PROTOTYPE_INHERITANCE_DEPTH_LIMIT: usize = 32; /// `objects.rlb` external geometry entry type (`EXTO`). pub const PROTOTYPE_TYPE_EXTO: u32 = u32::from_le_bytes(*b"EXTO"); +/// Unit DAT second-header-word bit that enables native BAS placement handling. +pub const UNIT_DAT_BAS_PLACEMENT_FLAG: u32 = 0x8000_0000; + /// Prototype key. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct PrototypeKey(pub ResourceName); @@ -40,6 +43,48 @@ pub struct UnitDat { pub records: Vec, } +/// BAS payload with two ordered point-record sections. +/// +/// The `first` and `second` fields describe stream order only. The native +/// loader reads one record shape in the first section and a point-only shape +/// in the second, but this type does not assign gameplay or geometry meaning +/// to either section. +#[derive(Clone, Debug, PartialEq)] +pub struct BasGeometry { + /// First ordered section, whose records also carry two opaque word arrays. + pub first: Vec, + /// Second ordered section of point sequences. + pub second: Vec, +} + +/// BAS record from the first ordered section. +#[derive(Clone, Debug, PartialEq)] +pub struct BasGeometryRecord { + /// Count value stored before the point sequence. + pub declared_count: u32, + /// `declared_count + 1` points copied by the native loader. + /// + /// Point sequences are preserved as read; closure is not imposed by the + /// binary decoder. + pub points: Vec<[f32; 3]>, + /// First opaque word array, containing `declared_count` entries. + pub opaque_words_a: Vec, + /// Second opaque word array, containing `declared_count` entries. + pub opaque_words_b: Vec, +} + +/// BAS point sequence from the second ordered section. +#[derive(Clone, Debug, PartialEq)] +pub struct BasPointSequence { + /// Count value stored before the point sequence. + pub declared_count: u32, + /// `declared_count + 1` points copied by the native loader. + /// + /// Point sequences are preserved as read; closure is not imposed by the + /// binary decoder. + pub points: Vec<[f32; 3]>, +} + /// Unit DAT binding used by mission object references. #[derive(Clone, Debug, Eq, PartialEq)] pub struct UnitDatBinding { @@ -117,6 +162,9 @@ pub struct EffectivePrototype { /// Explicit component NDP/provider resource referenced by this /// prototype's resolved `objects.rlb` registry inheritance chain. pub node_data_resource: Option, + /// Explicit BAS geometry resource referenced by this prototype's resolved + /// `objects.rlb` registry inheritance chain. + pub bas_resource: Option, } /// Prototype resolution source. @@ -146,6 +194,10 @@ pub struct PrototypeGraph { /// assigning runtime semantics to their `kind`, links, descriptions, or /// opaque tails. pub root_unit_components: Vec>, + /// Second word of each standard component Unit DAT header. This vector is + /// aligned with [`Self::roots`]; direct prototype roots, legacy one-model + /// bindings, and failed decodes are `None`. + pub root_unit_dat_header_flags: Vec>, /// Whether visual dependency expansion has already been applied. pub visual_dependencies_expanded: bool, /// Materialized prototype dependency graph nodes. @@ -568,6 +620,190 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result { }) } +/// Decodes a BAS payload as two ordered sections of point records. +/// +/// Each section begins with a record count. Every record stores a declared +/// count followed by that count plus one little-endian `Vec3` values. Records +/// in the first section then carry two arrays of `declared_count` opaque +/// words. The decoder preserves points and words without assigning geometric +/// meaning or requiring the point sequence to be closed. +/// +/// # Errors +/// +/// Returns [`PrototypeError::Decode`] when a count exceeds the shared binary +/// limits, a record is truncated, a point is not finite, or trailing bytes +/// remain. +pub fn decode_bas_geometry(payload: &[u8]) -> Result { + let limits = Limits::default(); + let payload_len = u64::try_from(payload.len()).map_err(|_| DecodeError::IntegerOverflow)?; + if payload_len > limits.max_file_bytes { + return Err(DecodeError::LimitExceeded { + count: payload_len, + limit: limits.max_file_bytes, + } + .into()); + } + + let mut cursor = Cursor::new(payload); + let mut budget = BasAllocationBudget::new(limits.max_decoded_bytes); + + let first_count = read_bas_count(&mut cursor, limits.max_entries)?; + check_bas_section_minimum(&cursor, first_count)?; + let first_capacity = + budget.reserve::(u64::from(first_count), limits.max_entries)?; + let mut first = Vec::with_capacity(first_capacity); + for _ in 0..first_count { + let declared_count = read_bas_count(&mut cursor, limits.max_array_items)?; + let point_count = bas_point_count(declared_count, limits.max_array_items)?; + check_bas_first_record(&cursor, declared_count, point_count)?; + + let points = read_bas_points(&mut cursor, point_count, &mut budget, limits)?; + let opaque_words_a = read_bas_words(&mut cursor, declared_count, &mut budget, limits)?; + let opaque_words_b = read_bas_words(&mut cursor, declared_count, &mut budget, limits)?; + first.push(BasGeometryRecord { + declared_count, + points, + opaque_words_a, + opaque_words_b, + }); + } + + let second_count = read_bas_count(&mut cursor, limits.max_entries)?; + check_bas_section_minimum(&cursor, second_count)?; + let second_capacity = + budget.reserve::(u64::from(second_count), limits.max_entries)?; + let mut second = Vec::with_capacity(second_capacity); + for _ in 0..second_count { + let declared_count = read_bas_count(&mut cursor, limits.max_array_items)?; + let point_count = bas_point_count(declared_count, limits.max_array_items)?; + let remaining = + u64::try_from(cursor.remaining()).map_err(|_| DecodeError::IntegerOverflow)?; + checked_count_bytes(point_count, 12, remaining)?; + let points = read_bas_points(&mut cursor, point_count, &mut budget, limits)?; + second.push(BasPointSequence { + declared_count, + points, + }); + } + + cursor.require_eof()?; + Ok(BasGeometry { first, second }) +} + +fn read_bas_count(cursor: &mut Cursor<'_>, limit: u32) -> Result { + let count = cursor.read_u32_le()?; + if count > limit { + return Err(DecodeError::LimitExceeded { + count: u64::from(count), + limit: u64::from(limit), + }); + } + Ok(count) +} + +fn bas_point_count(declared_count: u32, limit: u32) -> Result { + let point_count = u64::from(declared_count) + .checked_add(1) + .ok_or(DecodeError::IntegerOverflow)?; + if point_count > u64::from(limit) { + return Err(DecodeError::LimitExceeded { + count: point_count, + limit: u64::from(limit), + }); + } + Ok(point_count) +} + +fn check_bas_section_minimum(cursor: &Cursor<'_>, record_count: u32) -> Result<(), DecodeError> { + let remaining = u64::try_from(cursor.remaining()).map_err(|_| DecodeError::IntegerOverflow)?; + // Each record has a count word and at least one point (count zero). + checked_count_bytes(u64::from(record_count), 16, remaining)?; + Ok(()) +} + +fn check_bas_first_record( + cursor: &Cursor<'_>, + declared_count: u32, + point_count: u64, +) -> Result<(), DecodeError> { + let remaining = u64::try_from(cursor.remaining()).map_err(|_| DecodeError::IntegerOverflow)?; + let point_bytes = checked_count_bytes(point_count, 12, remaining)?; + let after_points = remaining + .checked_sub(u64::try_from(point_bytes).map_err(|_| DecodeError::IntegerOverflow)?) + .ok_or(DecodeError::IntegerOverflow)?; + // The two unnamed word arrays each contain `declared_count` u32 entries. + checked_count_bytes(u64::from(declared_count), 8, after_points)?; + Ok(()) +} + +fn read_bas_points( + cursor: &mut Cursor<'_>, + point_count: u64, + budget: &mut BasAllocationBudget, + limits: Limits, +) -> Result, DecodeError> { + let capacity = budget.reserve::<[f32; 3]>(point_count, limits.max_array_items)?; + let mut points = Vec::with_capacity(capacity); + for _ in 0..point_count { + let point = [ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ]; + if !point.iter().all(|value| value.is_finite()) { + return Err(DecodeError::Invalid("BAS point is not finite")); + } + points.push(point); + } + Ok(points) +} + +fn read_bas_words( + cursor: &mut Cursor<'_>, + word_count: u32, + budget: &mut BasAllocationBudget, + limits: Limits, +) -> Result, DecodeError> { + let capacity = budget.reserve::(u64::from(word_count), limits.max_array_items)?; + let mut words = Vec::with_capacity(capacity); + for _ in 0..word_count { + words.push(cursor.read_u32_le()?); + } + Ok(words) +} + +struct BasAllocationBudget { + used: u64, + limit: u64, +} + +impl BasAllocationBudget { + const fn new(limit: u64) -> Self { + Self { used: 0, limit } + } + + fn reserve(&mut self, count: u64, count_limit: u32) -> Result { + let capacity = checked_allocation_len(count, u64::from(count_limit))?; + let element_size = + u64::try_from(std::mem::size_of::()).map_err(|_| DecodeError::IntegerOverflow)?; + let allocation = count + .checked_mul(element_size) + .ok_or(DecodeError::IntegerOverflow)?; + let total = self + .used + .checked_add(allocation) + .ok_or(DecodeError::IntegerOverflow)?; + if total > self.limit { + return Err(DecodeError::LimitExceeded { + count: total, + limit: self.limit, + }); + } + self.used = total; + Ok(capacity) + } +} + /// Builds the immediate-child tree encoded by Unit DAT record counts. /// /// Records are stored in preorder. The final word of each 112-byte record is @@ -750,6 +986,7 @@ struct ResolvedPrototypeRequests { expected_count: usize, prototypes: Vec, unit_components: Vec, + unit_dat_header_flags: Option, } fn resolve_prototype_requests( @@ -766,6 +1003,7 @@ fn resolve_prototype_requests( expected_count: 1, prototypes: prototype.into_iter().collect(), unit_components: Vec::new(), + unit_dat_header_flags: None, }) } @@ -801,6 +1039,11 @@ fn resolve_unit_dat_prototype_requests( expected_count: unit.records.len(), prototypes, unit_components: unit.records, + unit_dat_header_flags: Some(u32::from_le_bytes( + unit.header_opaque[4..8] + .try_into() + .expect("fixed Unit DAT header slice"), + )), }); } @@ -813,6 +1056,7 @@ fn resolve_unit_dat_prototype_requests( expected_count: 1, prototypes: prototype.into_iter().collect(), unit_components: Vec::new(), + unit_dat_header_flags: None, }) } @@ -860,6 +1104,9 @@ pub fn build_prototype_graph( graph .root_unit_components .push(expansion.unit_components.clone()); + graph + .root_unit_dat_header_flags + .push(expansion.unit_dat_header_flags); let unit_component_count = expansion.unit_components.len(); for (prototype_index, prototype) in expansion.prototypes.into_iter().enumerate() { let unit_component_index = is_unit_dat_root @@ -966,6 +1213,9 @@ pub fn build_prototype_graph_report( graph .root_unit_components .push(expansion.unit_components.clone()); + graph + .root_unit_dat_header_flags + .push(expansion.unit_dat_header_flags); if edge == PrototypeGraphEdge::MissionToUnitDat { report.unit_component_count += expected; } @@ -1066,6 +1316,9 @@ pub fn build_prototype_graph_report( if graph.root_unit_components.len() <= root_index { graph.root_unit_components.push(Vec::new()); } + if graph.root_unit_dat_header_flags.len() <= root_index { + graph.root_unit_dat_header_flags.push(None); + } let end = graph.prototype_requests.len(); graph.root_prototype_request_spans.push(start..end); } @@ -1155,6 +1408,7 @@ fn resolve_archive_model( None, None, None, + None, ))) } @@ -1181,6 +1435,7 @@ fn resolve_objects_registry_model( let control_resource = resolve_explicit_control_resource(object_key, &refs)?; let point_resource = resolve_explicit_point_resource(object_key, &refs)?; let node_data_resource = resolve_explicit_node_data_resource(object_key, &refs)?; + let bas_resource = resolve_explicit_bas_resource(object_key, &refs)?; let mut missing_mesh_refs = Vec::new(); for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) { @@ -1193,6 +1448,7 @@ fn resolve_objects_registry_model( control_resource.clone(), point_resource.clone(), node_data_resource.clone(), + bas_resource.clone(), )? { return Ok(Some(prototype)); } @@ -1215,6 +1471,7 @@ fn resolve_objects_registry_model( control_resource, point_resource, node_data_resource, + bas_resource, })) } @@ -1284,6 +1541,7 @@ fn resolve_object_ref_model( control_resource: Option, point_resource: Option, node_data_resource: Option, + bas_resource: Option, ) -> Result, PrototypeError> { let archive = normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?; let Some(mesh) = find_mesh_resource(repository, &archive, &ResourceName(model_name.to_vec()))? @@ -1298,6 +1556,7 @@ fn resolve_object_ref_model( control_resource, point_resource, node_data_resource, + bas_resource, ))) } @@ -1317,6 +1576,10 @@ fn is_explicit_node_data_ref(item: &ObjectRefRecord) -> bool { has_extension_bytes(cstr_bytes(&item.resource_raw), b"ndp") } +fn is_explicit_bas_ref(item: &ObjectRefRecord) -> bool { + has_extension_bytes(cstr_bytes(&item.resource_raw), b"bas") +} + fn resolve_explicit_control_resource( requested: &ResourceName, refs: &[ObjectRefRecord], @@ -1338,6 +1601,13 @@ fn resolve_explicit_node_data_resource( resolve_explicit_resource(requested, refs, is_explicit_node_data_ref, "node data") } +fn resolve_explicit_bas_resource( + requested: &ResourceName, + refs: &[ObjectRefRecord], +) -> Result, PrototypeError> { + resolve_explicit_resource(requested, refs, is_explicit_bas_ref, "BAS geometry") +} + fn resolve_explicit_resource( requested: &ResourceName, refs: &[ObjectRefRecord], @@ -1442,6 +1712,7 @@ fn effective( control_resource: Option, point_resource: Option, node_data_resource: Option, + bas_resource: Option, ) -> EffectivePrototype { EffectivePrototype { key: PrototypeKey(requested), @@ -1452,6 +1723,7 @@ fn effective( control_resource, point_resource, node_data_resource, + bas_resource, } } @@ -1586,6 +1858,70 @@ mod tests { assert_eq!(cstr_bytes(&records[1].resource_raw), b"second.msh"); } + #[test] + fn bas_geometry_preserves_both_sections_and_open_point_sequences() { + let bytes = build_bas_geometry_payload(); + + let geometry = decode_bas_geometry(&bytes).expect("BAS geometry"); + + assert_eq!(geometry.first.len(), 2); + assert_eq!(geometry.first[0].declared_count, 1); + assert_eq!( + geometry.first[0].points, + vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]] + ); + assert_eq!(geometry.first[0].opaque_words_a, vec![0x1122_3344]); + assert_eq!(geometry.first[0].opaque_words_b, vec![0x5566_7788]); + assert_eq!(geometry.first[1].declared_count, 2); + assert_eq!( + geometry.first[1].points, + vec![[10.0, 11.0, 12.0], [13.0, 14.0, 15.0], [16.0, 17.0, 18.0]] + ); + assert_eq!(geometry.first[1].opaque_words_a, vec![7, 8]); + assert_eq!(geometry.first[1].opaque_words_b, vec![9, 10]); + assert_eq!(geometry.second.len(), 1); + assert_eq!(geometry.second[0].declared_count, 1); + assert_eq!( + geometry.second[0].points, + vec![[20.0, 21.0, 22.0], [23.0, 24.0, 25.0]] + ); + } + + #[test] + fn bas_geometry_rejects_truncated_records_and_extreme_counts() { + let mut truncated = build_bas_geometry_payload(); + truncated.pop(); + assert!(matches!( + decode_bas_geometry(&truncated), + Err(PrototypeError::Decode(DecodeError::UnexpectedEof { .. })) + )); + + assert!(matches!( + decode_bas_geometry(&u32::MAX.to_le_bytes()), + Err(PrototypeError::Decode(DecodeError::LimitExceeded { .. })) + )); + } + + #[test] + fn bas_geometry_rejects_non_finite_points_and_trailing_bytes() { + let mut non_finite = build_bas_geometry_payload(); + // The first point follows the first section and first record counts. + non_finite[8..12].copy_from_slice(&f32::NAN.to_bits().to_le_bytes()); + assert!(matches!( + decode_bas_geometry(&non_finite), + Err(PrototypeError::Decode(DecodeError::Invalid( + "BAS point is not finite" + ))) + )); + + let mut trailing = build_bas_geometry_payload(); + trailing.push(0); + assert!(matches!( + decode_bas_geometry(&trailing), + Err(PrototypeError::Decode(DecodeError::TrailingBytes { .. })) + )); + } + #[test] fn unit_zero_records_uses_exact_size() { let bytes = [0_u8; 8]; @@ -1628,6 +1964,10 @@ mod tests { let unit = decode_unit_dat(&bytes).expect("unit"); let record = &unit.records[0]; assert_eq!(unit.header_opaque, [0xF1, 0xF0, 1, 2, 3, 4, 5, 6]); + assert_eq!( + u32::from_le_bytes(unit.header_opaque[4..8].try_into().unwrap()), + 0x0605_0403 + ); assert_eq!(record.parent_or_link, -7); assert_eq!(&record.description_raw[..description.len()], description); assert_eq!(record.tail0, 0x1122_3344); @@ -1806,6 +2146,7 @@ mod tests { let (graph, resolved, report) = build_prototype_graph_report(&repo, vfs.as_ref(), &roots); assert_eq!(graph.roots.len(), 2); + assert_eq!(graph.root_unit_dat_header_flags, vec![None, None]); assert_eq!(resolved.len(), 1); assert_eq!(report.root_count, 2); assert_eq!(report.direct_reference_count, 2); @@ -1838,6 +2179,13 @@ mod tests { ); let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); + let (graph, _, report) = build_prototype_graph_report( + &repo, + vfs.as_ref(), + &[resource_name(b"UNITS/AUTO/unit.dat")], + ); + assert!(report.is_success()); + assert_eq!(graph.root_unit_dat_header_flags, vec![None]); let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"UNITS/AUTO/unit.dat")) .expect("resolve") @@ -1923,16 +2271,12 @@ mod tests { let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); let static_path = resource_archive_path(b"static.rlb").expect("static path"); let mesh = minimal_msh_payload(); - vfs.insert( - dat_path, - Arc::from( - build_unit_dat(&[ - (b"objects.rlb".as_slice(), b"component_a".as_slice()), - (b"objects.rlb".as_slice(), b"component_b".as_slice()), - ]) - .into_boxed_slice(), - ), - ); + let mut unit_dat = build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"component_a".as_slice()), + (b"objects.rlb".as_slice(), b"component_b".as_slice()), + ]); + unit_dat[4..8].copy_from_slice(&UNIT_DAT_BAS_PLACEMENT_FLAG.to_le_bytes()); + vfs.insert(dat_path, Arc::from(unit_dat.into_boxed_slice())); vfs.insert( objects_path, Arc::from( @@ -2077,16 +2421,12 @@ mod tests { let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); let static_path = resource_archive_path(b"static.rlb").expect("static path"); let mesh = minimal_msh_payload(); - vfs.insert( - dat_path, - Arc::from( - build_unit_dat(&[ - (b"objects.rlb".as_slice(), b"component_a".as_slice()), - (b"objects.rlb".as_slice(), b"component_b".as_slice()), - ]) - .into_boxed_slice(), - ), - ); + let mut unit_dat = build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"component_a".as_slice()), + (b"objects.rlb".as_slice(), b"component_b".as_slice()), + ]); + unit_dat[4..8].copy_from_slice(&UNIT_DAT_BAS_PLACEMENT_FLAG.to_le_bytes()); + vfs.insert(dat_path, Arc::from(unit_dat.into_boxed_slice())); vfs.insert( objects_path, Arc::from( @@ -2132,6 +2472,10 @@ mod tests { assert_eq!(graph.prototype_requests[1].0 .0, b"component_b"); assert_eq!(graph.root_unit_components.len(), 1); assert_eq!(graph.root_unit_components[0].len(), 2); + assert_eq!( + graph.root_unit_dat_header_flags, + vec![Some(UNIT_DAT_BAS_PLACEMENT_FLAG)] + ); assert_eq!( cstr_bytes(&graph.root_unit_components[0][0].resource_raw), b"component_a" @@ -2204,6 +2548,7 @@ mod tests { (b"fortif.rlb".as_slice(), b"child_proto.bas".as_slice()), (b"fortif.rlb".as_slice(), b"parent_proto.cpt".as_slice()), (b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()), + (b"fortif.rlb".as_slice(), b"parent_proto.ndp".as_slice()), ]) .as_slice(), ), @@ -2252,6 +2597,11 @@ mod tests { .expect("inherited explicit NDP provider table"); assert_eq!(node_data.archive.as_str(), "fortif.rlb"); assert!(node_data.name.0.eq_ignore_ascii_case(b"parent_proto.ndp")); + let bas = resolved + .bas_resource + .expect("child-local explicit BAS geometry"); + assert_eq!(bas.archive.as_str(), "fortif.rlb"); + assert!(bas.name.0.eq_ignore_ascii_case(b"child_proto.bas")); } #[test] @@ -2389,6 +2739,33 @@ mod tests { )); } + #[test] + fn objects_registry_rejects_distinct_explicit_bas_resources() { + let raw_name = |value: &[u8]| { + let mut raw = [0; 32]; + raw[..value.len()].copy_from_slice(value); + raw + }; + let refs = [ + ObjectRefRecord { + archive_raw: raw_name(b"first.rlb"), + resource_raw: raw_name(b"first.bas"), + }, + ObjectRefRecord { + archive_raw: raw_name(b"second.rlb"), + resource_raw: raw_name(b"second.bas"), + }, + ]; + + let error = resolve_explicit_bas_resource(&resource_name(b"prototype"), &refs) + .expect_err("distinct explicit BAS resources are ambiguous"); + assert!(matches!( + error, + PrototypeError::Resource(ResourceError::Format(message)) + if message.contains("ambiguous explicit BAS geometry resources") + )); + } + #[test] fn objects_registry_inheritance_resolves_multiple_levels() { let mut vfs = MemoryVfs::default(); @@ -2782,6 +3159,27 @@ mod tests { assert_eq!(mesh.archive.as_str().to_ascii_lowercase(), archive); assert!(mesh.name.0.eq_ignore_ascii_case(model)); } + + let plant = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"fr_b_plant")) + .expect("resolve factory prototype") + .expect("factory prototype"); + let bas = plant.bas_resource.expect("explicit BAS registry reference"); + assert_eq!(bas.archive.as_str().to_ascii_lowercase(), "fortif.rlb"); + assert!(bas.name.0.eq_ignore_ascii_case(b"fr_b_plant.bas")); + let bas_archive = repo + .open_archive(&bas.archive) + .expect("open explicit BAS archive"); + let bas_entry = repo + .find(bas_archive, &bas.name) + .expect("find explicit BAS entry") + .expect("BAS entry exists"); + let bas_payload = repo.read(bas_entry).expect("read explicit BAS entry"); + let geometry = + decode_bas_geometry(bas_payload.as_slice()).expect("decode licensed BAS geometry"); + assert_eq!(geometry.first.len(), 1); + assert_eq!(geometry.second.len(), 1); + assert_eq!(geometry.first[0].points.len(), 21); + assert_eq!(geometry.second[0].points.len(), 15); } #[test] @@ -3001,6 +3399,53 @@ mod tests { out } + fn build_bas_geometry_payload() -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&2_u32.to_le_bytes()); + + push_bas_record_with_words( + &mut out, + 1, + &[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + &[0x1122_3344], + &[0x5566_7788], + ); + push_bas_record_with_words( + &mut out, + 2, + &[[10.0, 11.0, 12.0], [13.0, 14.0, 15.0], [16.0, 17.0, 18.0]], + &[7, 8], + &[9, 10], + ); + + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + push_bas_points(&mut out, &[[20.0, 21.0, 22.0], [23.0, 24.0, 25.0]]); + out + } + + fn push_bas_record_with_words( + out: &mut Vec, + declared_count: u32, + points: &[[f32; 3]], + words_a: &[u32], + words_b: &[u32], + ) { + out.extend_from_slice(&declared_count.to_le_bytes()); + push_bas_points(out, points); + for word in words_a.iter().chain(words_b) { + out.extend_from_slice(&word.to_le_bytes()); + } + } + + fn push_bas_points(out: &mut Vec, points: &[[f32; 3]]) { + for point in points { + for value in point { + out.extend_from_slice(&value.to_le_bytes()); + } + } + } + fn build_object_refs(items: &[(&[u8], &[u8])]) -> Vec { let mut out = Vec::with_capacity(items.len() * 64); for (archive, resource) in items { diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index e0088a3..57bee81 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -12,7 +12,7 @@ use fparkan_assets::{ use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; use fparkan_prototype::{ build_prototype_graph_report, PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport, - UnitComponentRecord, + UnitComponentRecord, UNIT_DAT_BAS_PLACEMENT_FLAG, }; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; use fparkan_script::{ @@ -74,6 +74,13 @@ pub struct MissionObjectDraft { pub resource_name_raw: Vec, /// Raw identity/clan word from the mission. pub identity_or_clan_raw: u32, + /// Raw word after the mission object's instance name. Native BAS + /// placement uses its zero/nonzero state only when the Unit DAT header + /// gate is set. + pub raw_after_name: u32, + /// Second word of the root Unit DAT header, when it was decoded. A direct + /// prototype root has no Unit DAT header. + pub unit_dat_header_flags: Option, /// Raw position vector. pub position: [f32; 3], /// Raw orientation vector. @@ -91,6 +98,49 @@ pub struct MissionObjectDraft { pub unit_components: Vec, } +/// Native terrain adjustment selected for a mission object's BAS geometry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BasementPlacementMode { + /// Flatten the BAS inner boundary to mean outer terrain height and adjust + /// the object root transform by the native placement delta. + FlattenInnerAndAdjustRoot, + /// Keep authored BAS inner heights and leave the object root transform as + /// supplied by the mission. + PreserveAuthoredInnerHeight, +} + +impl MissionObjectDraft { + /// Returns the BAS mode selected by the native Unit DAT gate and mission + /// word, independent of resource path naming. + #[must_use] + pub fn basement_placement_mode(&self) -> Option { + let header_flags = self.unit_dat_header_flags?; + if header_flags & UNIT_DAT_BAS_PLACEMENT_FLAG == 0 { + return None; + } + Some(if self.raw_after_name == 0 { + BasementPlacementMode::FlattenInnerAndAdjustRoot + } else { + BasementPlacementMode::PreserveAuthoredInnerHeight + }) + } + + /// Returns this mission root's position after applying the native BAS + /// transform adjustment once. Native code subtracts + /// `authored_inner_z - mean_outer_z` from matrix element 11. Component + /// local transforms should continue to be composed beneath this adjusted + /// root position without applying the delta again. + #[must_use] + pub fn root_position_with_basement_delta(&self, placement_delta: f32) -> [f32; 3] { + let mut position = self.position; + if self.basement_placement_mode() == Some(BasementPlacementMode::FlattenInnerAndAdjustRoot) + { + position[2] -= placement_delta; + } + position + } +} + /// A compiled script bundle selected by one mission clan. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionScriptBundle { @@ -731,6 +781,12 @@ fn load_mission_with_options( original_id: u32::try_from(index).ok().map(OriginalObjectId), resource_name_raw: object.resource_raw.clone(), identity_or_clan_raw: object.identity_or_clan_raw, + raw_after_name: object.raw_after_name, + unit_dat_header_flags: prototype_graph + .root_unit_dat_header_flags + .get(index) + .copied() + .flatten(), position: object.position, orientation_raw: object.orientation, scale: object.scale, @@ -1329,6 +1385,58 @@ mod tests { use fparkan_vfs::{DirectoryVfs, MemoryVfs, VfsEntry, VfsMetadata}; use std::path::{Path, PathBuf}; + fn basement_draft(header_flags: Option, raw_after_name: u32) -> MissionObjectDraft { + MissionObjectDraft { + original_id: Some(OriginalObjectId(7)), + resource_name_raw: b"BUILDS/example.dat".to_vec(), + identity_or_clan_raw: 0, + raw_after_name, + unit_dat_header_flags: header_flags, + position: [12.0, 34.0, 100.0], + orientation_raw: [0.0; 3], + scale: [1.0; 3], + visual_ids: Vec::new(), + properties: Vec::new(), + unit_components: Vec::new(), + } + } + + #[test] + fn native_basement_mode_uses_unit_header_gate_not_builds_path() { + assert_eq!( + basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 0).basement_placement_mode(), + Some(BasementPlacementMode::FlattenInnerAndAdjustRoot) + ); + assert_eq!( + basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 1).basement_placement_mode(), + Some(BasementPlacementMode::PreserveAuthoredInnerHeight) + ); + assert_eq!(basement_draft(Some(0), 0).basement_placement_mode(), None); + assert_eq!(basement_draft(None, 1).basement_placement_mode(), None); + } + + #[test] + fn native_basement_delta_adjusts_only_flattened_root_once() { + let flatten = basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 0); + let root = flatten.root_position_with_basement_delta(3.25); + assert_eq!(root, [12.0, 34.0, 96.75]); + // A component remains local to the root; it receives the root shift + // through composition instead of applying the delta itself. + let child_local_z = 8.0; + assert_eq!(root[2] + child_local_z, 104.75); + + let preserve = basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 0x40); + assert_eq!( + preserve.root_position_with_basement_delta(3.25), + preserve.position + ); + let no_gate = basement_draft(Some(0), 0); + assert_eq!( + no_gate.root_position_with_basement_delta(3.25), + no_gate.position + ); + } + #[test] fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() { let mut engine = create(EngineServices::default()).expect("engine"); -- cgit v1.2.3