feat: complete map viewer scene and static CTL pose preview

Complete the interactive mission viewer with environment rendering, audio events, dynamic shadows, free-flight camera controls, and per-component CTL pose sampling.
This commit is contained in:
2026-10-11 13:12:50 +04:00
parent aa51f3574d
commit a8faba8aad
42 changed files with 24411 additions and 1234 deletions
+401 -16
View File
@@ -15,6 +15,9 @@ const UNIT_DAT_MIN_SIZE: usize = 0x48;
const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1;
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");
/// Prototype key.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct PrototypeKey(pub ResourceName);
@@ -61,10 +64,21 @@ pub struct UnitComponentRecord {
pub parent_or_link: i32,
/// Description raw bytes.
pub description_raw: [u8; 32],
/// Opaque tail.
/// Opaque record word at offset `0x68`.
pub tail0: u32,
/// Opaque tail.
pub tail1: u32,
/// Number of immediate children in the preorder unit tree.
pub immediate_child_count: u32,
}
/// One node in a decoded Unit DAT preorder tree.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UnitComponentTreeNode {
/// Original preorder record index.
pub record_index: usize,
/// Parent record index, or `None` for the root.
pub parent_index: Option<usize>,
/// Direct child record indices in source order.
pub children: Vec<usize>,
}
/// Prototype geometry.
@@ -85,8 +99,18 @@ pub struct EffectivePrototype {
pub geometry: PrototypeGeometry,
/// Resolution source.
pub source: PrototypeSource,
/// Source registry entry type, when the prototype came from an NRes
/// object registry (for example `EXTO`, `INTO`, or `BTLU`).
pub source_type: Option<u32>,
/// Resource dependencies discovered while resolving this prototype.
pub dependencies: Vec<ResourceKey>,
/// Explicit control-data resource referenced by this prototype's resolved
/// `objects.rlb` registry inheritance chain, when present.
///
/// This is deliberately separate from visual dependencies: controller
/// state belongs to each component instance and must not alter shared
/// mesh/material cache identity.
pub control_resource: Option<ResourceKey>,
}
/// Prototype resolution source.
@@ -401,6 +425,8 @@ pub enum PrototypeError {
InvalidSize,
/// Invalid unit DAT magic.
InvalidUnitDatMagic(u32),
/// Unit DAT child counts do not describe one complete preorder tree.
InvalidUnitHierarchy(String),
/// Invalid path.
InvalidPath(String),
/// VFS error.
@@ -435,6 +461,9 @@ impl std::fmt::Display for PrototypeError {
Self::InvalidUnitDatMagic(magic) => {
write!(f, "invalid unit DAT magic: {magic:#010X}")
}
Self::InvalidUnitHierarchy(message) => {
write!(f, "invalid unit DAT preorder hierarchy: {message}")
}
Self::InvalidPath(value) => write!(f, "invalid path: {value}"),
Self::Vfs(source) => write!(f, "vfs error: {source}"),
Self::Resource(source) => write!(f, "resource error: {source}"),
@@ -446,7 +475,10 @@ impl std::error::Error for PrototypeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Decode(source) => Some(source),
Self::InvalidSize | Self::InvalidUnitDatMagic(_) | Self::InvalidPath(_) => None,
Self::InvalidSize
| Self::InvalidUnitDatMagic(_)
| Self::InvalidUnitHierarchy(_)
| Self::InvalidPath(_) => None,
Self::Vfs(source) => Some(source),
Self::Resource(source) => Some(source),
}
@@ -512,7 +544,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
let parent_or_link = cursor.read_i32_le()?;
description_raw.copy_from_slice(cursor.read_exact(32)?);
let tail0 = cursor.read_u32_le()?;
let tail1 = cursor.read_u32_le()?;
let immediate_child_count = cursor.read_u32_le()?;
records.push(UnitComponentRecord {
archive_raw,
resource_raw,
@@ -520,7 +552,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
parent_or_link,
description_raw,
tail0,
tail1,
immediate_child_count,
});
}
cursor.require_eof()?;
@@ -530,6 +562,86 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
})
}
/// 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
/// the number of immediate children, so the tree can be reconstructed without
/// treating `parent_or_link` as a parent index. That field is retained as the
/// native mount-socket value for attachment consumers.
///
/// # Errors
///
/// Returns [`PrototypeError::InvalidUnitHierarchy`] when child counts consume
/// too few or too many records.
pub fn unit_component_tree(
records: &[UnitComponentRecord],
) -> Result<Vec<UnitComponentTreeNode>, PrototypeError> {
if records.is_empty() {
return Ok(Vec::new());
}
let root_child_count = usize::try_from(records[0].immediate_child_count).map_err(|_| {
PrototypeError::InvalidUnitHierarchy("record 0 child count does not fit usize".to_string())
})?;
let mut nodes = Vec::with_capacity(records.len());
nodes.push(UnitComponentTreeNode {
record_index: 0,
parent_index: None,
children: Vec::new(),
});
// Each open frame holds a preorder node and the direct children still due.
// This keeps hostile deep chains off the call stack.
let mut open_parents = vec![(0_usize, root_child_count)];
let mut cursor = 1_usize;
while cursor < records.len() {
while open_parents
.last()
.is_some_and(|(_, remaining_children)| *remaining_children == 0)
{
open_parents.pop();
}
let Some((parent_node_index, remaining_children)) = open_parents.last_mut() else {
return Err(PrototypeError::InvalidUnitHierarchy(format!(
"root tree consumed {cursor} records, but {} were encoded",
records.len()
)));
};
let parent_node_index = *parent_node_index;
*remaining_children -= 1;
let record_index = cursor;
let record = &records[record_index];
let child_count = usize::try_from(record.immediate_child_count).map_err(|_| {
PrototypeError::InvalidUnitHierarchy(format!(
"record {record_index} child count does not fit usize"
))
})?;
let node_index = nodes.len();
nodes.push(UnitComponentTreeNode {
record_index,
parent_index: Some(nodes[parent_node_index].record_index),
children: Vec::new(),
});
nodes[parent_node_index].children.push(node_index);
cursor += 1;
open_parents.push((node_index, child_count));
}
while open_parents
.last()
.is_some_and(|(_, remaining_children)| *remaining_children == 0)
{
open_parents.pop();
}
if !open_parents.is_empty() {
return Err(PrototypeError::InvalidUnitHierarchy(format!(
"child count reached record {cursor}, but only {} records exist",
records.len()
)));
}
Ok(nodes)
}
/// Decodes a mission unit DAT binding.
///
/// # Errors
@@ -1029,7 +1141,7 @@ fn resolve_archive_model(
let Some(mesh) = find_mesh_resource(repository, archive, model_key)? else {
return Ok(None);
};
Ok(Some(effective(model_key.clone(), mesh, source)))
Ok(Some(effective(model_key.clone(), mesh, source, None, None)))
}
fn resolve_objects_registry_model(
@@ -1037,17 +1149,33 @@ fn resolve_objects_registry_model(
registry_archive: &NormalizedPath,
object_key: &ResourceName,
) -> Result<Option<EffectivePrototype>, PrototypeError> {
let source_type = match repository.open_archive(registry_archive) {
Ok(archive_id) => {
find_any_candidate(repository, archive_id, &mesh_name_candidates(&object_key.0))?
.map(|(entry, _)| repository.entry_info(entry))
.transpose()?
.and_then(|info| info.key.type_id)
}
Err(ResourceError::MissingArchive { .. }) => return Ok(None),
Err(error) => return Err(error.into()),
};
let Some(refs) =
collect_registry_refs(repository, registry_archive, object_key, &mut Vec::new(), 0)?
else {
return Ok(None);
};
let control_resource = resolve_explicit_control_resource(object_key, &refs)?;
let mut missing_mesh_refs = Vec::new();
for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) {
if let Some(prototype) =
resolve_object_ref_model(repository, object_key, item, cstr_bytes(&item.resource_raw))?
{
if let Some(prototype) = resolve_object_ref_model(
repository,
object_key,
item,
cstr_bytes(&item.resource_raw),
source_type,
control_resource.clone(),
)? {
return Ok(Some(prototype));
}
missing_mesh_refs.push(describe_object_ref(item));
@@ -1064,7 +1192,9 @@ fn resolve_objects_registry_model(
key: PrototypeKey(object_key.clone()),
geometry: PrototypeGeometry::NonGeometric,
source: PrototypeSource::ObjectsRegistry,
source_type,
dependencies: Vec::new(),
control_resource,
}))
}
@@ -1130,6 +1260,8 @@ fn resolve_object_ref_model(
requested: &ResourceName,
item: &ObjectRefRecord,
model_name: &[u8],
source_type: Option<u32>,
control_resource: Option<ResourceKey>,
) -> Result<Option<EffectivePrototype>, 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()))?
@@ -1140,6 +1272,8 @@ fn resolve_object_ref_model(
requested.clone(),
mesh,
PrototypeSource::ObjectsRegistry,
source_type,
control_resource,
)))
}
@@ -1147,6 +1281,61 @@ fn is_explicit_mesh_ref(item: &ObjectRefRecord) -> bool {
has_extension_bytes(cstr_bytes(&item.resource_raw), b"msh")
}
fn is_explicit_control_ref(item: &ObjectRefRecord) -> bool {
has_extension_bytes(cstr_bytes(&item.resource_raw), b"ctl")
}
fn resolve_explicit_control_resource(
requested: &ResourceName,
refs: &[ObjectRefRecord],
) -> Result<Option<ResourceKey>, PrototypeError> {
let mut resolved = Vec::new();
for item in refs.iter().filter(|item| is_explicit_control_ref(item)) {
let archive =
normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?;
let name = resource_name(cstr_bytes(&item.resource_raw));
// CTLD is an optional pose sidecar. Keep the exact registry reference
// here, then let asset preparation treat a missing archive/entry as an
// absent override while still rejecting a present malformed payload.
let key = ResourceKey {
archive,
name,
type_id: None,
};
if !resolved
.iter()
.any(|existing| same_resource_key(existing, &key))
{
resolved.push(key);
}
}
match resolved.len() {
0 => Ok(None),
1 => Ok(resolved.pop()),
_ => Err(PrototypeError::Resource(ResourceError::Format(format!(
"prototype {} has ambiguous explicit control resources: {}",
String::from_utf8_lossy(&requested.0),
resolved
.iter()
.map(|key| format!(
"{}:{}",
key.archive.as_str(),
String::from_utf8_lossy(&key.name.0)
))
.collect::<Vec<_>>()
.join(", "),
)))),
}
}
fn same_resource_key(left: &ResourceKey, right: &ResourceKey) -> bool {
left.archive
.as_str()
.eq_ignore_ascii_case(right.archive.as_str())
&& eq_ignore_ascii_case(&left.name.0, &right.name.0)
&& left.type_id == right.type_id
}
fn describe_object_ref(item: &ObjectRefRecord) -> String {
format!(
"{}:{}",
@@ -1195,12 +1384,16 @@ fn effective(
requested: ResourceName,
mesh: ResourceKey,
source: PrototypeSource,
source_type: Option<u32>,
control_resource: Option<ResourceKey>,
) -> EffectivePrototype {
EffectivePrototype {
key: PrototypeKey(requested),
geometry: PrototypeGeometry::Mesh(mesh.clone()),
source,
source_type,
dependencies: vec![mesh],
control_resource,
}
}
@@ -1380,7 +1573,7 @@ mod tests {
assert_eq!(record.parent_or_link, -7);
assert_eq!(&record.description_raw[..description.len()], description);
assert_eq!(record.tail0, 0x1122_3344);
assert_eq!(record.tail1, 0x5566_7788);
assert_eq!(record.immediate_child_count, 0x5566_7788);
}
#[test]
@@ -1406,6 +1599,88 @@ mod tests {
assert_eq!(unit.records[0].parent_or_link, 12);
}
#[test]
fn unit_component_tree_follows_preorder_child_counts_and_keeps_mount_links() {
let mut bytes = build_unit_dat(&[
(b"objects.rlb".as_slice(), b"root".as_slice()),
(b"objects.rlb".as_slice(), b"child".as_slice()),
(b"objects.rlb".as_slice(), b"grandchild".as_slice()),
]);
bytes[8 + 68..8 + 72].copy_from_slice(&(-1_i32).to_le_bytes());
bytes[8 + 108..8 + 112].copy_from_slice(&1_u32.to_le_bytes());
bytes[8 + 112 + 68..8 + 112 + 72].copy_from_slice(&7_i32.to_le_bytes());
bytes[8 + 112 + 108..8 + 112 + 112].copy_from_slice(&1_u32.to_le_bytes());
bytes[8 + 224 + 68..8 + 224 + 72].copy_from_slice(&3_i32.to_le_bytes());
let unit = decode_unit_dat(&bytes).expect("unit");
let tree = unit_component_tree(&unit.records).expect("preorder tree");
assert_eq!(tree.len(), 3);
assert_eq!(tree[0].record_index, 0);
assert_eq!(tree[0].parent_index, None);
assert_eq!(tree[0].children, vec![1]);
assert_eq!(tree[1].parent_index, Some(0));
assert_eq!(tree[1].children, vec![2]);
assert_eq!(tree[2].parent_index, Some(1));
assert_eq!(tree[2].children, Vec::<usize>::new());
assert_eq!(unit.records[1].parent_or_link, 7);
assert_eq!(unit.records[2].parent_or_link, 3);
}
#[test]
fn unit_component_tree_handles_deep_preorder_chains_iteratively() {
const DEPTH: usize = 32_768;
let records: Vec<_> = (0..DEPTH)
.map(|index| UnitComponentRecord {
archive_raw: [0; 32],
resource_raw: [0; 32],
kind: 0,
parent_or_link: 0,
description_raw: [0; 32],
tail0: 0,
immediate_child_count: u32::from(index + 1 < DEPTH),
})
.collect();
let tree = unit_component_tree(&records).expect("deep preorder chain");
assert_eq!(tree.len(), DEPTH);
assert_eq!(tree[0].parent_index, None);
assert_eq!(tree[0].children, vec![1]);
assert_eq!(tree[DEPTH / 2].parent_index, Some(DEPTH / 2 - 1));
assert_eq!(tree[DEPTH - 2].children, vec![DEPTH - 1]);
assert_eq!(tree[DEPTH - 1].parent_index, Some(DEPTH - 2));
assert!(tree[DEPTH - 1].children.is_empty());
}
#[test]
fn unit_component_tree_rejects_missing_child_records() {
let bytes = build_unit_dat(&[
(b"objects.rlb".as_slice(), b"root".as_slice()),
(b"objects.rlb".as_slice(), b"child".as_slice()),
]);
let mut unit = decode_unit_dat(&bytes).expect("unit");
unit.records[0].immediate_child_count = 2;
assert!(matches!(
unit_component_tree(&unit.records),
Err(PrototypeError::InvalidUnitHierarchy(message))
if message.contains("child count reached record 2")
));
}
#[test]
fn unit_component_tree_rejects_unconsumed_preorder_records() {
let bytes = build_unit_dat(&[
(b"objects.rlb".as_slice(), b"root".as_slice()),
(b"objects.rlb".as_slice(), b"child".as_slice()),
]);
let unit = decode_unit_dat(&bytes).expect("unit");
assert!(matches!(
unit_component_tree(&unit.records),
Err(PrototypeError::InvalidUnitHierarchy(_))
));
}
#[test]
fn resolves_synthetic_objects_registry_model() {
let mut vfs = MemoryVfs::default();
@@ -1436,6 +1711,7 @@ mod tests {
.expect("prototype");
assert_eq!(resolved.source, PrototypeSource::ObjectsRegistry);
assert!(resolved.control_resource.is_none());
let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
panic!("expected mesh");
};
@@ -1855,10 +2131,10 @@ mod tests {
build_nres(&[
(
b"parent_proto".as_slice(),
build_object_refs(&[(
b"static.rlb".as_slice(),
b"parent_proto.msh".as_slice(),
)])
build_object_refs(&[
(b"static.rlb".as_slice(), b"parent_proto.msh".as_slice()),
(b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()),
])
.as_slice(),
),
(
@@ -1866,6 +2142,7 @@ mod tests {
build_object_refs(&[
(b"objects.rlb".as_slice(), b"parent_proto".as_slice()),
(b"fortif.rlb".as_slice(), b"child_proto.bas".as_slice()),
(b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()),
])
.as_slice(),
),
@@ -1881,7 +2158,13 @@ mod tests {
);
vfs.insert(
fortif_path,
Arc::from(build_nres(&[(b"child_proto.bas".as_slice(), b"base")]).into_boxed_slice()),
Arc::from(
build_nres(&[
(b"child_proto.bas".as_slice(), b"base"),
(b"parent_proto.ctl".as_slice(), b"control"),
])
.into_boxed_slice(),
),
);
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
@@ -1895,6 +2178,108 @@ mod tests {
};
assert_eq!(mesh.archive.as_str(), "static.rlb");
assert!(mesh.name.0.eq_ignore_ascii_case(b"parent_proto.msh"));
let control = resolved.control_resource.expect("inherited explicit CTL");
assert_eq!(control.archive.as_str(), "fortif.rlb");
assert!(control.name.0.eq_ignore_ascii_case(b"parent_proto.ctl"));
}
#[test]
fn objects_registry_retains_missing_explicit_control_metadata() {
let mut vfs = MemoryVfs::default();
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(
objects_path,
Arc::from(
build_nres(&[(
b"optional_control".as_slice(),
build_object_refs(&[
(b"static.rlb".as_slice(), b"optional_control.msh".as_slice()),
(
b"missing.rlb".as_slice(),
b"optional_control.ctl".as_slice(),
),
])
.as_slice(),
)])
.into_boxed_slice(),
),
);
vfs.insert(
static_path,
Arc::from(
build_nres(&[(b"optional_control.msh".as_slice(), mesh.as_slice())])
.into_boxed_slice(),
),
);
let vfs = Arc::new(vfs);
let repository = CachedResourceRepository::new(vfs.clone());
let resolved = resolve_prototype_single(
&repository,
vfs.as_ref(),
&resource_name(b"optional_control"),
)
.expect("resolve despite absent optional CTL")
.expect("prototype");
let control = resolved
.control_resource
.expect("preserve explicit missing CTL reference");
assert_eq!(control.archive.as_str(), "missing.rlb");
assert_eq!(control.name, resource_name(b"optional_control.ctl"));
assert_eq!(control.type_id, None);
}
#[test]
fn objects_registry_rejects_distinct_explicit_controls() {
let mut vfs = MemoryVfs::default();
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 controls_path = resource_archive_path(b"controls.rlb").expect("controls path");
let mesh = minimal_msh_payload();
vfs.insert(
objects_path,
Arc::from(
build_nres(&[(
b"prototype".as_slice(),
build_object_refs(&[
(b"static.rlb".as_slice(), b"prototype.msh".as_slice()),
(b"controls.rlb".as_slice(), b"first.ctl".as_slice()),
(b"controls.rlb".as_slice(), b"second.ctl".as_slice()),
])
.as_slice(),
)])
.into_boxed_slice(),
),
);
vfs.insert(
static_path,
Arc::from(
build_nres(&[(b"prototype.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(),
),
);
vfs.insert(
controls_path,
Arc::from(
build_nres(&[
(b"first.ctl".as_slice(), b"first control"),
(b"second.ctl".as_slice(), b"second control"),
])
.into_boxed_slice(),
),
);
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
let error = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"prototype"))
.expect_err("distinct explicit controls are ambiguous");
assert!(matches!(
error,
PrototypeError::Resource(ResourceError::Format(message))
if message.contains("ambiguous explicit control resources")
));
}
#[test]