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
+46 -4
View File
@@ -254,12 +254,16 @@ impl AnimKey24 {
];
let time = AnimationTime(read_f32(bytes, 12)?);
validate_time(time)?;
let raw_rotation = [
let disk_wxyz = [
f32::from(read_i16(bytes, 16)?) / 32767.0,
f32::from(read_i16(bytes, 18)?) / 32767.0,
f32::from(read_i16(bytes, 20)?) / 32767.0,
f32::from(read_i16(bytes, 22)?) / 32767.0,
];
// Native AniMesh stores the scalar first (WXYZ). The portable pose
// contract is XYZW, matching the rotate/multiply helpers below. Keep
// the decoded values unnormalized; sampling_pose owns normalization.
let raw_rotation = [disk_wxyz[1], disk_wxyz[2], disk_wxyz[3], disk_wxyz[0]];
Ok(Self {
time,
pose: Pose {
@@ -870,9 +874,47 @@ mod tests {
assert_eq!(key.time, AnimationTime(12.5));
assert_eq!(key.pose.translation, [-1.0, 2.0, 0.0]);
assert!(key.pose.rotation[1] < 0.0);
assert!((key.pose.rotation[1] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
assert!((key.pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
assert!(key.pose.rotation[0] < 0.0);
assert!((key.pose.rotation[0] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
assert!((key.pose.rotation[2] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
}
#[test]
fn anim_key24_preserves_disk_wxyz_as_internal_xyzw() {
let mut bytes = [0_u8; 24];
bytes[12..16].copy_from_slice(&0.0_f32.to_bits().to_le_bytes());
bytes[16..18].copy_from_slice(&32609_i16.to_le_bytes());
bytes[18..20].copy_from_slice(&0_i16.to_le_bytes());
bytes[20..22].copy_from_slice(&0_i16.to_le_bytes());
bytes[22..24].copy_from_slice(&3211_i16.to_le_bytes());
let key = AnimKey24::decode(&bytes).expect("bunker key");
let expected = [0.0, 0.0, 3211.0 / 32767.0, 32609.0 / 32767.0];
assert_eq!(key.pose.rotation, expected);
}
#[test]
fn bunker_fallback_pose_rotates_a_point_with_native_matrix_contract() {
let mut bytes = [0_u8; 24];
bytes[12..16].copy_from_slice(&0.0_f32.to_bits().to_le_bytes());
bytes[16..18].copy_from_slice(&32609_i16.to_le_bytes());
bytes[18..20].copy_from_slice(&0_i16.to_le_bytes());
bytes[20..22].copy_from_slice(&0_i16.to_le_bytes());
bytes[22..24].copy_from_slice(&3211_i16.to_le_bytes());
let pose = AnimKey24::decode(&bytes)
.expect("bunker key")
.sampling_pose();
let rotated = rotate_point(pose.rotation, [0.0, 1.0, 0.0]);
assert!((pose.rotation[0] - 0.0).abs() < 0.000_001);
assert!((pose.rotation[1] - 0.0).abs() < 0.000_001);
assert!((pose.rotation[2] - 0.097_995_8).abs() < 0.000_01);
assert!((pose.rotation[3] - 0.995_186_8).abs() < 0.000_01);
assert!((rotated[0] + 0.195_048_3).abs() < 0.000_01);
assert!((rotated[1] - 0.980_793_6).abs() < 0.000_01);
assert!(rotated[2].abs() < 0.000_01);
}
#[test]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+33 -7
View File
@@ -6,6 +6,11 @@
//! reference stub until opcode timing, gates, RNG, and command-body semantics
//! are backed by runtime-captured evidence.
pub mod atmosphere;
pub mod environment;
pub mod shadow;
pub mod sky;
use fparkan_binary::{Cursor, DecodeError};
use std::sync::Arc;
@@ -85,8 +90,13 @@ pub struct FxCommand {
pub word: u32,
/// Decoded opcode.
pub opcode: FxOpcode,
/// Enabled bit.
pub enabled: bool,
/// Native command flag stored in bit 8 of the command word.
///
/// This flag is passed to the native command object as a mode/feature
/// flag. It does not disable the command: real effects such as
/// `env_lightning` use zero for all three command flags and still execute
/// opcode 3, opcode 1, and opcode 2.
pub native_flag: bool,
/// Command body after the word.
pub raw_body: Arc<[u8]>,
/// Resource references discovered in known command layouts.
@@ -314,7 +324,7 @@ pub fn decode_fxid(bytes: Arc<[u8]>) -> Result<FxDocument, FxError> {
commands.push(FxCommand {
word,
opcode,
enabled: ((word >> 8) & 1) != 0,
native_flag: ((word >> 8) & 1) != 0,
raw_body,
resource_refs,
});
@@ -369,9 +379,6 @@ pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> {
return Ok(());
}
for (index, command) in state.document.commands.iter().enumerate() {
if !command.enabled {
continue;
}
let command_index = u32::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?;
if command.opcode == FxOpcode::Op2 {
out.push(FxEmission::Sound(FxSoundEvent { command_index }));
@@ -605,7 +612,7 @@ mod tests {
assert_eq!(document.header().command_count, 2);
assert_eq!(document.commands()[0].opcode, FxOpcode::Op2);
assert!(document.commands()[0].enabled);
assert!(document.commands()[0].native_flag);
assert_eq!(
document.commands()[0].resource_refs[0].archive_name(),
b"sounds.lib"
@@ -640,6 +647,25 @@ mod tests {
assert!(document.commands()[0].raw_body.is_empty());
}
#[test]
fn zero_native_flags_still_emit_every_command() {
let mut bytes = header(3);
bytes.extend_from_slice(&command(0x0003, 200));
bytes.extend_from_slice(&command(0x0001, 224));
bytes.extend_from_slice(&command(0x0002, 148));
let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"));
assert!(document
.commands()
.iter()
.all(|command| !command.native_flag));
let state = create_instance(document, FxSeed(9), Transform::default()).expect("state");
assert_eq!(
canonical_emission_capture(&state).expect("capture"),
b"P,0, Op3\nP,1, Op1\nS,2\n"
);
}
#[test]
fn rejects_unknown_opcode_at_command_index() {
let mut bytes = header(1);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+622 -90
View File
@@ -18,6 +18,9 @@ pub const STREAM_POSITIONS: u32 = 3;
pub const STREAM_NORMALS: u32 = 4;
/// Texture coordinate stream.
pub const STREAM_UV0: u32 = 5;
/// Optional secondary texture coordinate stream used by native lightmap/detail
/// stages. MSH stores it as the type-18 four-byte packed UV stream.
pub const STREAM_UV1: u32 = 18;
/// Triangle index stream.
pub const STREAM_INDICES: u32 = 6;
/// Animation key stream.
@@ -81,14 +84,20 @@ pub struct ModelAsset {
pub node_count: usize,
/// Raw node table.
pub nodes_raw: Vec<u8>,
/// Model-level bounding sphere center from the Res2 header.
pub bounding_sphere_center: [f32; 3],
/// Model-level bounding sphere radius from the Res2 header.
pub bounding_sphere_radius: f32,
/// Slot table.
pub slots: Vec<Slot>,
/// Vertex positions.
pub positions: Vec<[f32; 3]>,
/// Optional normals.
pub normals: Option<Vec<[i8; 4]>>,
/// Optional texture coordinates.
pub uv0: Option<Vec<[i16; 2]>>,
/// Optional primary packed texture coordinates from type 5.
pub uv0: Option<Vec<[u16; 2]>>,
/// Optional secondary packed texture coordinates from type 18.
pub uv1: Option<Vec<[u16; 2]>>,
/// Triangle indices.
pub indices: Vec<u16>,
/// Draw batches.
@@ -167,18 +176,24 @@ pub struct Slot {
pub struct Batch {
/// Batch flags.
pub batch_flags: u16,
/// Material index.
pub material_index: u16,
/// Opaque field.
pub opaque4: u16,
/// Opaque field.
pub opaque6: u16,
/// High material selector/slot field at byte offset `+0x02`.
///
/// The ordinary WEAR material selector is the byte at `+0x04`. This
/// field is retained separately because the native path can override it
/// with a forced material index.
pub material_index_hi: u16,
/// WEAR material selector at byte offset `+0x04`.
pub material_index: u8,
/// Lightmap selector at byte offset `+0x05`.
pub lightmap_index: u8,
/// Local batch index at byte offset `+0x06`.
pub local_batch_index: u16,
/// Index count.
pub index_count: u16,
/// First index offset.
pub index_start: u32,
/// Opaque field.
pub opaque14: u16,
/// Vertex count at byte offset `+0x0E`.
pub vertex_count: u16,
/// Base vertex.
pub base_vertex: u32,
}
@@ -194,8 +209,10 @@ pub struct VertexStreams {
pub positions: Vec<[f32; 3]>,
/// Optional normals.
pub normals: Option<Vec<[i8; 4]>>,
/// Optional texture coordinates.
pub uv0: Option<Vec<[i16; 2]>>,
/// Optional primary packed texture coordinates from type 5.
pub uv0: Option<Vec<[u16; 2]>>,
/// Optional secondary packed texture coordinates from type 18.
pub uv1: Option<Vec<[u16; 2]>>,
}
/// Preserved non-core stream.
@@ -209,11 +226,11 @@ pub struct PreservedStream {
pub bytes: std::sync::Arc<[u8]>,
}
/// LOD id.
/// Native state id kept under the historical `Lod` API name.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Lod(pub u8);
/// Group id.
/// Native LOD id kept under the historical `Group` API name.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Group(pub u8);
@@ -377,7 +394,10 @@ pub fn validate_msh(document: &MshDocument) -> Result<ModelAsset, MshError> {
Ok(model)
}
/// Returns the selected slot for a node/lod/group tuple.
/// Returns the selected slot for a node/native-state/native-LOD tuple.
///
/// The argument names are retained for API compatibility: `Lod` is the
/// three-value native state and `Group` is the five-value native LOD.
#[must_use]
pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -> Option<SlotId> {
if model.node_stride != 38 || lod.0 >= 3 || group.0 >= 5 {
@@ -417,12 +437,13 @@ pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadat
})
}
/// Returns the source fallback pose of a standard node.
/// Returns the fallback key as a native Node38 source-local pose.
///
/// The legacy sampler uses this key when a node has no frame map or its current
/// frame falls outside that map. It is a static-pose input only; callers must
/// still recover animation state and the meaning of `parent_or_link_raw` before
/// assembling a runtime hierarchy.
/// assembling a runtime hierarchy. Its quaternion is converted to match native
/// AniMesh's quaternion-to-matrix convention.
#[must_use]
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
let metadata = node38_metadata(model, node)?;
@@ -432,6 +453,18 @@ pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
.keys
.get(usize::from(metadata.fallback_key))
.map(AnimKey24::sampling_pose)
.map(node38_native_local_pose)
}
/// Converts a sampled MSH source-local quaternion into the native AniMesh
/// quaternion-matrix convention before Node38 hierarchy composition. Native
/// AniMesh's matrix coefficients are the transpose of the portable `Pose`
/// basis.
fn node38_native_local_pose(mut pose: Pose) -> Pose {
pose.rotation[0] = -pose.rotation[0];
pose.rotation[1] = -pose.rotation[1];
pose.rotation[2] = -pose.rotation[2];
pose
}
/// Evaluates the static fallback pose hierarchy of a standard `Node38` model.
@@ -462,8 +495,9 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
evaluate_hierarchy(&parents, &poses).ok()
}
/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
/// model at one explicitly supplied logical frame.
/// Evaluates a standard `Node38` hierarchy at one explicitly supplied logical
/// frame, using portable key sampling and native AniMesh local-matrix
/// convention.
///
/// A node without a usable type-19 map, or a requested frame outside the
/// declared map length, retains its exact fallback key. Mapped keys are
@@ -475,6 +509,59 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
/// representation or the map cannot be safely resolved.
#[must_use]
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
node38_sampled_hierarchy_with_selector(model, |_| {
Some((u32::from(frame), AnimationTime(f32::from(frame))))
})
}
/// Evaluates a standard `Node38` hierarchy with an independent floating-point
/// sample time for each node.
///
/// `Some(time)` uses the nearest-even integer to `time - 0.5` as the type-19
/// frame-map selector, then samples the selected key and its immediate
/// successor at the original fractional time. This matches the native
/// sampler's selector calculation while keeping interpolation time fractional.
/// The selector uses Rust's default-nearest, ties-to-even rounding. This is
/// the portable counterpart of the native x87 conversion under its default
/// rounding mode; it does not claim parity under every x87 control-word mode.
/// A missing time uses that node's exact fallback key. Supplied times must be
/// finite and non-negative; the caller owns choosing the per-node times.
///
/// Returns `None` when the model is not a complete standard-node animation,
/// the slice does not match the node count, any supplied time is invalid, or a
/// frame-map entry cannot be safely resolved.
#[must_use]
pub fn node38_sampled_hierarchy_at_times(
model: &ModelAsset,
node_times: &[Option<AnimationTime>],
) -> Option<NodePoseBuffer> {
if node_times.len() != model.node_count
|| node_times
.iter()
.flatten()
.any(|time| !time.0.is_finite() || time.0 < 0.0)
{
return None;
}
node38_sampled_hierarchy_with_selector(model, |index| {
let time = node_times[index]?;
let selector = (time.0 - 0.5).round_ties_even();
// A finite, non-negative time can still be beyond the representable
// map range. Saturate to a frame that will resolve to the node's
// fallback key rather than allowing a float-to-index wraparound.
let map_frame = if selector < 0.0 || selector >= u32::MAX as f32 {
u32::MAX
} else {
selector as u32
};
Some((map_frame, time))
})
}
fn node38_sampled_hierarchy_with_selector(
model: &ModelAsset,
mut selector_for_node: impl FnMut(usize) -> Option<(u32, AnimationTime)>,
) -> Option<NodePoseBuffer> {
if model.node_stride != 38 || model.node_count == 0 {
return None;
}
@@ -492,21 +579,36 @@ pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePo
};
let fallback_index = usize::from(metadata.fallback_key);
let _fallback = animation.keys.get(fallback_index)?;
let key_index =
if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
let sample = selector_for_node(index);
let key_index = if let Some((map_frame, _)) = sample {
if metadata.anim_map_start == u16::MAX
|| map_frame == u32::MAX
|| map_frame >= animation.frame_count
{
fallback_index
} else {
let mapped_index =
usize::from(*animation.frame_map.get(
usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
)?);
let mapped_index = usize::from(
*animation.frame_map.get(
usize::from(metadata.anim_map_start)
.checked_add(usize::try_from(map_frame).ok()?)?,
)?,
);
if mapped_index < fallback_index {
mapped_index
} else {
fallback_index
}
};
let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
}
} else {
fallback_index
};
let sample_time = sample.map_or(AnimationTime(0.0), |(_, time)| time);
let pose = node38_native_local_pose(sample_node38_key_pair(
&animation.keys,
key_index,
fallback_index,
sample_time,
)?);
parents.push(parent);
poses.push(pose);
}
@@ -517,7 +619,7 @@ fn sample_node38_key_pair(
keys: &[AnimKey24],
key_index: usize,
fallback_index: usize,
frame: u16,
sample_time: AnimationTime,
) -> Option<Pose> {
let key = *keys.get(key_index)?;
if key_index == fallback_index {
@@ -541,7 +643,7 @@ fn sample_node38_key_pair(
],
)
.ok()?;
track.sample(AnimationTime(f32::from(frame))).ok()
track.sample(sample_time).ok()
}
/// Returns draw batches for a validated slot.
@@ -667,6 +769,8 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
}
let node_count = nodes_stream.bytes.len() / node_stride;
let (bounding_sphere_center, bounding_sphere_radius) =
parse_res2_bounding_sphere(&slots_stream.bytes)?;
let slots = parse_slots(&slots_stream.bytes)?;
let positions = parse_positions(&positions_stream.bytes)?;
let indices = parse_u16_array(&indices_stream.bytes, "Res6")?;
@@ -678,7 +782,10 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
.map(|raw| parse_i8x4_array(&raw.bytes, "Res4"))
.transpose()?;
let uv0 = read_optional_stream(document, STREAM_UV0)?
.map(|raw| parse_i16x2_array(&raw.bytes, "Res5"))
.map(|raw| parse_u16x2_array(&raw.bytes, "Res5"))
.transpose()?;
let uv1 = read_optional_stream(document, STREAM_UV1)?
.map(|raw| parse_secondary_uv_stream(raw, positions.len()))
.transpose()?;
let node_names = read_optional_stream(document, STREAM_NAMES)?
.map(|raw| parse_res10_names(&raw.bytes, node_count))
@@ -689,10 +796,13 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
node_stride,
node_count,
nodes_raw: nodes_stream.bytes,
bounding_sphere_center,
bounding_sphere_radius,
slots,
positions,
normals,
uv0,
uv1,
indices,
batches,
node_names,
@@ -822,6 +932,27 @@ fn parse_slots(data: &[u8]) -> Result<Vec<Slot>, MshError> {
Ok(slots)
}
fn parse_res2_bounding_sphere(data: &[u8]) -> Result<([f32; 3], f32), MshError> {
if data.len() < 0x8C {
return Err(MshError::InvalidGeometry(format!(
"invalid Res2 size: {}",
data.len()
)));
}
let center = [
read_f32(data, 0x60)?,
read_f32(data, 0x64)?,
read_f32(data, 0x68)?,
];
let radius = read_f32(data, 0x6C)?;
if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius < 0.0 {
return Err(MshError::InvalidGeometry(
"invalid Res2 bounding sphere".to_string(),
));
}
Ok((center, radius))
}
fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>, MshError> {
if !data.len().is_multiple_of(12) {
return Err(invalid_resource_size("Res3", data.len(), 12));
@@ -845,12 +976,17 @@ fn parse_batches(data: &[u8]) -> Result<Vec<Batch>, MshError> {
for offset in (0..data.len()).step_by(20) {
out.push(Batch {
batch_flags: read_u16_required(data, offset)?,
material_index: read_u16_required(data, offset + 2)?,
opaque4: read_u16_required(data, offset + 4)?,
opaque6: read_u16_required(data, offset + 6)?,
material_index_hi: read_u16_required(data, offset + 2)?,
material_index: *data
.get(offset + 4)
.ok_or_else(|| MshError::InvalidGeometry("batch material selector".to_string()))?,
lightmap_index: *data
.get(offset + 5)
.ok_or_else(|| MshError::InvalidGeometry("batch lightmap selector".to_string()))?,
local_batch_index: read_u16_required(data, offset + 6)?,
index_count: read_u16_required(data, offset + 8)?,
index_start: read_u32(data, offset + 10)?,
opaque14: read_u16_required(data, offset + 14)?,
vertex_count: read_u16_required(data, offset + 14)?,
base_vertex: read_u32(data, offset + 16)?,
});
}
@@ -884,17 +1020,51 @@ fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>, Ms
Ok(out)
}
fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>, MshError> {
fn parse_u16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[u16; 2]>, MshError> {
if !data.len().is_multiple_of(4) {
return Err(invalid_resource_size(label, data.len(), 4));
}
let mut out = Vec::with_capacity(data.len() / 4);
for offset in (0..data.len()).step_by(4) {
out.push([read_i16(data, offset)?, read_i16(data, offset + 2)?]);
out.push([
read_u16_required(data, offset)?,
read_u16_required(data, offset + 2)?,
]);
}
Ok(out)
}
fn parse_secondary_uv_stream(
stream: RawStream,
position_count: usize,
) -> Result<Vec<[u16; 2]>, MshError> {
if stream.attributes.attr3 != 4 {
return Err(MshError::InvalidGeometry(format!(
"invalid Res18 stride: expected 4, got {}",
stream.attributes.attr3
)));
}
let values = parse_u16x2_array(&stream.bytes, "Res18")?;
let declared_count = usize::try_from(stream.attributes.attr1).map_err(|_| {
MshError::InvalidGeometry("Res18 vertex count does not fit usize".to_string())
})?;
if declared_count != values.len() {
return Err(MshError::InvalidGeometry(format!(
"Res18 vertex count does not match payload: declared={}, decoded={}",
declared_count,
values.len()
)));
}
if values.len() != position_count {
return Err(MshError::InvalidGeometry(format!(
"Res18 vertex count does not match Res3: uv1={}, positions={}",
values.len(),
position_count
)));
}
Ok(values)
}
fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>, MshError> {
let mut out = Vec::with_capacity(node_count);
let mut offset = 0usize;
@@ -1038,16 +1208,6 @@ fn read_u16_required(bytes: &[u8], offset: usize) -> Result<u16, MshError> {
Ok(u16::from_le_bytes(arr))
}
fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, MshError> {
let raw = bytes
.get(offset..offset.saturating_add(2))
.ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
let arr: [u8; 2] = raw
.try_into()
.map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?;
Ok(i16::from_le_bytes(arr))
}
fn read_i8(bytes: &[u8], offset: usize) -> Result<i8, MshError> {
let byte = bytes
.get(offset)
@@ -1309,18 +1469,21 @@ mod tests {
let mut nodes = root;
nodes.extend(child);
let mut keys = Vec::new();
for (x, y, z, qz, qw) in [
// Type 8 stores WXYZ, which `AnimKey24::decode` reorders to XYZW.
// The native Node38 local-basis conversion happens before hierarchy
// evaluation, not in the generic animation decoder.
for (x, y, z, qw, qz) in [
(1.0, 0.0, 0.0, 23_170_i16, 23_170_i16),
(2.0, 0.0, 0.0, 0_i16, 32_767_i16),
(2.0, 0.0, 0.0, 32_767_i16, 0_i16),
] {
push_f32(&mut keys, x);
push_f32(&mut keys, y);
push_f32(&mut keys, z);
push_f32(&mut keys, 0.0);
push_u16(&mut keys, qw.cast_unsigned());
push_u16(&mut keys, 0);
push_u16(&mut keys, 0);
push_u16(&mut keys, qz.cast_unsigned());
push_u16(&mut keys, qw.cast_unsigned());
}
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes),
@@ -1336,7 +1499,78 @@ mod tests {
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy");
assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001);
assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
assert!((hierarchy.poses[1].translation[1] + 2.0).abs() < 0.001);
}
#[test]
fn node38_native_x_rotation_moves_child_translation_for_fallback_and_sampled_paths() {
let mut root = node38([u16::MAX; 15]);
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
root[4..6].copy_from_slice(&0_u16.to_le_bytes());
root[6..8].copy_from_slice(&1_u16.to_le_bytes());
let mut child = node38([u16::MAX; 15]);
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
child[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
child[6..8].copy_from_slice(&2_u16.to_le_bytes());
let mut nodes = root;
nodes.extend(child);
let half_sqrt_two = std::f32::consts::FRAC_1_SQRT_2;
let root_rotation = [half_sqrt_two, 0.0, 0.0, half_sqrt_two];
let identity = [0.0, 0.0, 0.0, 1.0];
let child_translation = [0.0, -1.01209, -0.001792];
let model = ModelAsset {
node_stride: 38,
node_count: 2,
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 1.0,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
uv1: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: [0.0; 3],
rotation: root_rotation,
},
},
AnimKey24 {
time: AnimationTime(1.0),
pose: Pose {
translation: [0.0; 3],
rotation: root_rotation,
},
},
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: child_translation,
rotation: identity,
},
},
],
frame_map: vec![0],
frame_count: 1,
}),
};
let fallback_root = node38_fallback_pose(&model, NodeId(0)).expect("fallback root pose");
assert!(fallback_root.rotation[0] < -0.7);
let fallback = node38_fallback_hierarchy(&model).expect("fallback hierarchy");
let sampled = node38_sampled_hierarchy(&model, 0).expect("sampled hierarchy");
for hierarchy in [&fallback, &sampled] {
let child_translation = hierarchy.poses[1].translation;
assert!(child_translation[0].abs() < 0.0001);
assert!((child_translation[1] + 0.001792).abs() < 0.0001);
assert!((child_translation[2] - 1.01209).abs() < 0.0001);
}
}
#[test]
@@ -1349,10 +1583,13 @@ mod tests {
node_stride: 38,
node_count: 1,
nodes_raw: node,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 1.0,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
uv1: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
@@ -1394,6 +1631,105 @@ mod tests {
);
}
#[test]
fn standard_nodes_sample_independent_fractional_times_and_selector_rounding() {
let mut root = node38([u16::MAX; 15]);
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
root[4..6].copy_from_slice(&0_u16.to_le_bytes());
root[6..8].copy_from_slice(&4_u16.to_le_bytes());
let mut child = node38([u16::MAX; 15]);
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
child[4..6].copy_from_slice(&2_u16.to_le_bytes());
child[6..8].copy_from_slice(&5_u16.to_le_bytes());
let mut nodes = root;
nodes.extend(child);
let model = ModelAsset {
node_stride: 38,
node_count: 2,
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 1.0,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
uv1: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: [4.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(2.0),
pose: Pose {
translation: [8.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(1.0),
pose: Pose {
translation: [20.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(2.0),
pose: Pose {
translation: [30.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(9.0),
pose: Pose {
translation: [100.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(9.0),
pose: Pose {
translation: [300.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
],
frame_map: vec![0, 1, 0, 2],
frame_count: 4,
}),
};
let hierarchy = node38_sampled_hierarchy_at_times(
&model,
&[Some(AnimationTime(1.0)), Some(AnimationTime(1.55))],
)
.expect("per-node fractional hierarchy");
// The root's tie selects map frame zero and interpolates to x=6. The
// child independently selects frame one and samples its own 1.55 time;
// its global pose includes the root translation.
assert!((hierarchy.poses[0].translation[0] - 6.0).abs() < f32::EPSILON);
assert!((hierarchy.poses[1].translation[0] - 31.5).abs() < 0.001);
let fallback = node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0)), None])
.expect("per-node fallback hierarchy");
assert_eq!(fallback.poses[1].translation[0], 306.0);
assert!(node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0))]).is_none());
for invalid in [-1.0, f32::NAN, f32::INFINITY] {
assert!(node38_sampled_hierarchy_at_times(
&model,
&[Some(AnimationTime(invalid)), None]
)
.is_none());
}
}
#[test]
fn type2_header_and_slot_tail_framing_are_exact() {
let too_small = decode_nested(&build_nres(&[
@@ -1420,6 +1756,44 @@ mod tests {
assert!(matches!(err, MshError::InvalidGeometry(_)));
}
#[test]
fn type2_header_bounding_sphere_is_preserved_and_rejects_invalid_values() {
let valid_slots = slots_payload_with_bounds([1.25, -2.5, 3.75], 4.5, &[]);
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
stream(STREAM_SLOTS, 0, b"Res2", &valid_slots),
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
]))
.expect("nested");
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
assert_eq!(model.bounding_sphere_center, [1.25, -2.5, 3.75]);
assert_eq!(model.bounding_sphere_radius, 4.5);
for (offset, value) in [
(0x60, f32::NAN),
(0x68, f32::INFINITY),
(0x6C, f32::NAN),
(0x6C, -1.0),
] {
let mut slots = slots_payload(&[]);
slots[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
stream(STREAM_SLOTS, 0, b"Res2", &slots),
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
]))
.expect("nested");
assert!(matches!(
validate_msh(&decode_msh(&document).expect("msh")),
Err(MshError::InvalidGeometry(_))
));
}
}
#[test]
fn slot_batch_range_out_of_bounds_is_error() {
let slots = slots_payload(&[slot_record(1, 1, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0)]);
@@ -1448,6 +1822,7 @@ mod tests {
(STREAM_POSITIONS, b"Res3".as_slice(), vec![0; 11]),
(STREAM_NORMALS, b"Res4".as_slice(), vec![0; 3]),
(STREAM_UV0, b"Res5".as_slice(), vec![0; 3]),
(STREAM_UV1, b"Res18".as_slice(), vec![0; 3]),
(STREAM_INDICES, b"Res6".as_slice(), vec![0; 1]),
] {
let slots = slots_payload(&[]);
@@ -1471,6 +1846,127 @@ mod tests {
}
}
#[test]
fn type18_secondary_uv_decodes_unsigned_pairs_for_each_source_vertex() {
let positions = positions_payload(&[[0.0, 0.0, 0.0]; 4]);
let mut uv1 = Vec::new();
for pair in [[2_u16, 5_u16], [12, 8], [16, 30], [34, 2]] {
push_u16(&mut uv1, pair[0]);
push_u16(&mut uv1, pair[1]);
}
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
stream(STREAM_POSITIONS, 12, b"Res3", &positions),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
stream_with_attr1(STREAM_UV1, 4, 4, b"Res18", &uv1),
]))
.expect("nested");
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
assert_eq!(model.positions.len(), 4);
assert_eq!(model.uv1, Some(vec![[2, 5], [12, 8], [16, 30], [34, 2]]));
}
#[test]
fn type18_secondary_uv_rejects_untrusted_count_or_stride() {
let positions = positions_payload(&[[0.0, 0.0, 0.0]; 1]);
for (attr1, attr3, expected) in [(1, 8, "stride"), (2, 4, "count")] {
let mut uv1 = Vec::new();
push_u16(&mut uv1, 2);
push_u16(&mut uv1, 5);
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
stream(STREAM_POSITIONS, 12, b"Res3", &positions),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
stream_with_attr1(STREAM_UV1, attr1, attr3, b"Res18", &uv1),
]))
.expect("nested");
let err = validate_msh(&decode_msh(&document).expect("msh")).expect_err("invalid");
let message = err.to_string();
assert!(message.contains(expected), "{message}");
}
}
#[test]
fn installed_gog_secondary_uv_fixture_is_aligned_and_real() {
let Some(root) = std::env::var_os("FPARKAN_GAME_ROOT") else {
return;
};
let root = PathBuf::from(root);
assert!(
root.is_dir(),
"FPARKAN_GAME_ROOT is missing: {}",
root.display()
);
let mut models_with_uv1 = 0usize;
let mut fixture_found = false;
for path in files_under(&root) {
if !path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("rlb"))
{
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(archive) =
fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
else {
continue;
};
for entry in archive
.entries()
.iter()
.filter(|entry| has_msh_extension(entry.name_bytes()))
{
let payload = archive.payload(entry.id()).expect("payload");
let nested = fparkan_nres::decode(
Arc::from(payload.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
)
.unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
let msh = decode_msh(&nested)
.unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
let model = validate_msh(&msh)
.unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
let Some(uv1) = model.uv1.as_ref() else {
continue;
};
models_with_uv1 += 1;
assert_eq!(
uv1.len(),
model.positions.len(),
"{path:?} {:?}",
entry.name_bytes()
);
let descriptor = msh
.streams()
.iter()
.find(|stream| stream.type_id == STREAM_UV1)
.expect("Res18 descriptor");
assert_eq!(descriptor.attributes.attr1 as usize, uv1.len());
assert_eq!(descriptor.attributes.attr3, 4);
if entry.name_bytes().eq_ignore_ascii_case(b"fr_l_gener.msh") {
assert_eq!(&uv1[..4], &[[2, 5], [12, 8], [16, 30], [34, 2]]);
fixture_found = true;
}
}
}
assert!(models_with_uv1 > 0, "FPARKAN_GAME_ROOT has no type18 MSH");
assert!(
fixture_found,
"FPARKAN_GAME_ROOT lacks fr_l_gener.msh fixture"
);
}
#[test]
fn batch20_uses_unaligned_field_offsets() {
let positions = positions_payload(&[[0.0, 0.0, 0.0]]);
@@ -1488,12 +1984,13 @@ mod tests {
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
assert_eq!(model.batches[0].batch_flags, 0x1100);
assert_eq!(model.batches[0].material_index, 0x2200);
assert_eq!(model.batches[0].opaque4, 0x3300);
assert_eq!(model.batches[0].opaque6, 0x4400);
assert_eq!(model.batches[0].material_index_hi, 0x2200);
assert_eq!(model.batches[0].material_index, 0x00);
assert_eq!(model.batches[0].lightmap_index, 0x33);
assert_eq!(model.batches[0].local_batch_index, 0x4400);
assert_eq!(model.batches[0].index_count, 1);
assert_eq!(model.batches[0].index_start, 0);
assert_eq!(model.batches[0].opaque14, 0x5500);
assert_eq!(model.batches[0].vertex_count, 0x5500);
assert_eq!(model.batches[0].base_vertex, 0);
}
@@ -1587,44 +2084,49 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_msh_assets_validate() {
for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] {
let root = corpus_root(corpus);
let mut count = 0usize;
for path in files_under(&root) {
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(archive) = fparkan_nres::decode(
Arc::from(bytes.into_boxed_slice()),
fn licensed_corpus_part1_msh_assets_validate() {
validate_licensed_corpus_msh_assets("IS", 435);
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_part2_msh_assets_validate() {
validate_licensed_corpus_msh_assets("IS2", 511);
}
fn validate_licensed_corpus_msh_assets(corpus: &str, expected: usize) {
let root = corpus_root(corpus);
let mut count = 0usize;
for path in files_under(&root) {
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(archive) =
fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
else {
continue;
};
for entry in archive
.entries()
.iter()
.filter(|entry| has_msh_extension(entry.name_bytes()))
{
let payload = archive.payload(entry.id()).expect("payload");
let nested = fparkan_nres::decode(
Arc::from(payload.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
) else {
continue;
};
for entry in archive
.entries()
.iter()
.filter(|entry| has_msh_extension(entry.name_bytes()))
{
let payload = archive.payload(entry.id()).expect("payload");
let nested = fparkan_nres::decode(
Arc::from(payload.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
)
.unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
});
let msh = decode_msh(&nested).unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
});
validate_msh(&msh).unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
});
count += 1;
}
)
.unwrap_or_else(|err| panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()));
let msh = decode_msh(&nested).unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
});
validate_msh(&msh).unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
});
count += 1;
}
assert_eq!(count, expected, "{corpus} MSH count");
}
assert_eq!(count, expected, "{corpus} MSH count");
}
#[test]
@@ -1932,6 +2434,23 @@ mod tests {
fn stream<'a>(type_id: u32, attr3: u32, name: &'a [u8], payload: &'a [u8]) -> TestEntry<'a> {
TestEntry {
type_id,
attr1: 0,
attr3,
name,
payload,
}
}
fn stream_with_attr1<'a>(
type_id: u32,
attr1: u32,
attr3: u32,
name: &'a [u8],
payload: &'a [u8],
) -> TestEntry<'a> {
TestEntry {
type_id,
attr1,
attr3,
name,
payload,
@@ -1940,6 +2459,7 @@ mod tests {
struct TestEntry<'a> {
type_id: u32,
attr1: u32,
attr3: u32,
name: &'a [u8],
payload: &'a [u8],
@@ -1958,7 +2478,7 @@ mod tests {
order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name));
for (idx, entry) in entries.iter().enumerate() {
push_u32(&mut out, entry.type_id);
push_u32(&mut out, 0);
push_u32(&mut out, entry.attr1);
push_u32(&mut out, 0);
push_u32(
&mut out,
@@ -2000,7 +2520,19 @@ mod tests {
}
fn slots_payload(records: &[Vec<u8>]) -> Vec<u8> {
slots_payload_with_bounds([0.0; 3], 1.0, records)
}
fn slots_payload_with_bounds(center: [f32; 3], radius: f32, records: &[Vec<u8>]) -> Vec<u8> {
let mut out = vec![0; 0x8c];
for (offset, value) in [
(0x60, center[0]),
(0x64, center[1]),
(0x68, center[2]),
(0x6C, radius),
] {
out[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
for record in records {
assert_eq!(record.len(), 68);
out.extend_from_slice(record);
+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]
+85 -3
View File
@@ -319,7 +319,7 @@ impl LegacyIron3dEulerTransform {
/// Fixed-function blend behaviour represented without a graphics API type.
///
/// This is a compatibility contract, not yet a decoded MAT0 mapping.
/// This is the compatibility contract for the native MAT0 pipeline category.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LegacyBlendMode {
/// Do not blend the fragment with the existing colour.
@@ -327,6 +327,14 @@ pub enum LegacyBlendMode {
Opaque,
/// Blend using source alpha.
SourceAlpha,
/// Add the source-alpha-scaled fragment to the existing colour.
Additive,
/// Replace the source factor with zero and use source colour as the
/// destination factor.
ZeroSourceColor,
/// Multiply the source by destination colour and destination by source
/// colour, matching the native two-colour blend mode.
DestColorSourceColor,
}
/// Depth-buffer behaviour represented without a graphics API type.
@@ -369,10 +377,36 @@ pub struct LegacyPipelineState {
pub alpha_test: bool,
}
impl LegacyPipelineState {
/// Applies the native MAT0 pipeline category while retaining the caller's
/// depth and cull state.
///
/// Categories 0..4 are the complete table recovered from the native
/// material path. The native table enables `GREATER_EQUAL` alpha testing
/// for categories 1..4; the alpha reference remains per draw range.
#[must_use]
pub const fn with_material_category(self, category: u8) -> Option<Self> {
let (blend, alpha_test) = match category {
0 => (LegacyBlendMode::Opaque, false),
1 => (LegacyBlendMode::SourceAlpha, true),
2 => (LegacyBlendMode::Additive, true),
3 => (LegacyBlendMode::ZeroSourceColor, true),
4 => (LegacyBlendMode::DestColorSourceColor, true),
_ => return None,
};
Some(Self {
blend,
alpha_test,
..self
})
}
}
/// Canonical, backend-neutral key for a graphics-pipeline variant.
///
/// The value is explicitly packed rather than hashed, so captures and caches
/// remain stable across processes and Rust toolchain updates.
/// remain stable across processes and Rust toolchain updates. Three bits are
/// reserved for the five native material blend modes.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PipelineKey(u8);
@@ -389,6 +423,9 @@ impl From<LegacyPipelineState> for PipelineKey {
let blend = match state.blend {
LegacyBlendMode::Opaque => 0,
LegacyBlendMode::SourceAlpha => 1,
LegacyBlendMode::Additive => 2,
LegacyBlendMode::ZeroSourceColor => 3,
LegacyBlendMode::DestColorSourceColor => 4,
};
let depth = match state.depth {
LegacyDepthMode::Disabled => 0,
@@ -400,7 +437,7 @@ impl From<LegacyPipelineState> for PipelineKey {
LegacyCullMode::BackFace => 1,
LegacyCullMode::FrontFace => 2,
};
Self(blend | (depth << 1) | (cull << 3) | (u8::from(state.alpha_test) << 5))
Self(blend | (depth << 3) | (cull << 5) | (u8::from(state.alpha_test) << 7))
}
}
@@ -549,4 +586,49 @@ mod tests {
None
);
}
#[test]
fn native_material_categories_preserve_depth_and_cull_state() {
let base = LegacyPipelineState {
depth: LegacyDepthMode::TestWrite,
cull: LegacyCullMode::BackFace,
..LegacyPipelineState::default()
};
let expected = [
(LegacyBlendMode::Opaque, false),
(LegacyBlendMode::SourceAlpha, true),
(LegacyBlendMode::Additive, true),
(LegacyBlendMode::ZeroSourceColor, true),
(LegacyBlendMode::DestColorSourceColor, true),
];
for (category, (blend, alpha_test)) in expected.into_iter().enumerate() {
let state = base
.with_material_category(category as u8)
.expect("native category is represented");
assert_eq!(state.blend, blend);
assert_eq!(state.alpha_test, alpha_test);
assert_eq!(state.depth, base.depth);
assert_eq!(state.cull, base.cull);
}
assert_eq!(base.with_material_category(5), None);
}
#[test]
fn native_material_categories_have_distinct_pipeline_keys() {
let base = LegacyPipelineState {
depth: LegacyDepthMode::TestWrite,
..LegacyPipelineState::default()
};
let keys: Vec<_> = (0..=4)
.map(|category| {
PipelineKey::from(
base.with_material_category(category)
.expect("native category is represented"),
)
})
.collect();
for (index, key) in keys.iter().enumerate() {
assert!(keys[index + 1..].iter().all(|other| other != key));
}
}
}
+5 -1
View File
@@ -1761,7 +1761,7 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_load_all_mission_foundations() {
fn licensed_corpus_part1_loads_all_mission_foundations() {
let part1 = load_all(&licensed_root("IS"));
assert_eq!(part1.missions, 29);
assert_eq!(part1.paths, 34);
@@ -1781,7 +1781,11 @@ mod tests {
assert_eq!(part1.material_slots, part1.material_resolved);
assert_eq!(part1.texture_requests, part1.texture_resolved);
assert_eq!(part1.lightmap_requests, part1.lightmap_resolved);
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_part2_loads_all_mission_foundations() {
let part2 = load_all(&licensed_root("IS2"));
assert_eq!(part2.missions, 31);
assert_eq!(part2.paths, 61);
+108 -41
View File
@@ -43,16 +43,18 @@ pub struct CompactSurfaceMask(pub u16);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MaterialClassMask(pub u8);
/// The two positional material-table selectors packed into a terrain face tag.
/// The base and overlay selectors packed into a terrain face tag.
///
/// The high byte selects from map-local `Land1.wea`; `0xff` is the observed
/// no-selection sentinel. The low byte selects from `Land2.wea`.
/// Both selectors address the map-local `Land1.wea` table. The low byte is
/// always the base row; the high byte is an optional overlay row and uses
/// `0xff` as its no-overlay sentinel. `Land2.wea` is a separate detail bank
/// selected by the corresponding detail path.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialLayers {
/// Optional high-byte selector for `Land1.wea`.
pub land1_selector: Option<u8>,
/// Low-byte selector for `Land2.wea`.
pub land2_selector: u8,
/// Low-byte base selector for `Land1.wea`.
pub base_selector: u8,
/// Optional high-byte overlay selector for `Land1.wea`.
pub overlay_selector: Option<u8>,
}
/// One `World3D` material-manager lookup key.
@@ -79,31 +81,56 @@ impl TerrainMaterialLayers {
/// Constructs the decoded pair from its on-disk packed tag.
#[must_use]
pub const fn from_packed_tag(material_tag: u16) -> Self {
let [land2_selector, land1] = material_tag.to_le_bytes();
let [base_selector, overlay] = material_tag.to_le_bytes();
Self {
land1_selector: if land1 == u8::MAX { None } else { Some(land1) },
land2_selector,
base_selector,
overlay_selector: if overlay == u8::MAX {
None
} else {
Some(overlay)
},
}
}
/// Returns the table-zero `Land1.wea` selection when it exists.
/// Returns the base selection from `Land1.wea`.
#[must_use]
pub fn land1_selection(self) -> Option<TerrainMaterialSelection> {
self.land1_selector
pub fn base_selection(self) -> TerrainMaterialSelection {
TerrainMaterialSelection {
table_index: 0,
material_index: u16::from(self.base_selector),
}
}
/// Returns the optional overlay selection from `Land1.wea`.
#[must_use]
pub fn overlay_selection(self) -> Option<TerrainMaterialSelection> {
self.overlay_selector
.map(|material_index| TerrainMaterialSelection {
table_index: 0,
material_index: u16::from(material_index),
})
}
/// Returns the table-one `Land2.wea` selection.
/// Returns the detail selection corresponding to the base row in
/// `Land2.wea`.
#[must_use]
pub fn land2_selection(self) -> TerrainMaterialSelection {
pub fn detail_base_selection(self) -> TerrainMaterialSelection {
TerrainMaterialSelection {
table_index: 1,
material_index: u16::from(self.land2_selector),
material_index: u16::from(self.base_selector),
}
}
/// Returns the optional detail selection corresponding to the overlay row
/// in `Land2.wea`.
#[must_use]
pub fn detail_overlay_selection(self) -> Option<TerrainMaterialSelection> {
self.overlay_selector
.map(|material_index| TerrainMaterialSelection {
table_index: 1,
material_index: u16::from(material_index),
})
}
}
/// Terrain face with 28-byte source layout.
@@ -231,7 +258,7 @@ pub struct LandMeshDocument {
/// Packed normals from type 4.
pub normals: Vec<[i8; 4]>,
/// Packed UV from type 5.
pub uv0: Vec<[i16; 2]>,
pub uv0: Vec<[u16; 2]>,
/// Type 11 accelerator words.
pub accelerator: Vec<[u8; 4]>,
/// Type 14 auxiliary words.
@@ -631,7 +658,7 @@ pub fn decode_land_msh(nres: &NresDocument) -> Result<LandMeshDocument, TerrainF
validate_slots(nres, slots)?;
let positions = parse_positions(nres, positions)?;
let normals = parse_i8x4_stream(nres, TYPE_NORMALS, normals)?;
let uv0 = parse_i16x2_stream(nres, TYPE_UV0, uv0)?;
let uv0 = parse_u16x2_stream(nres, TYPE_UV0, uv0)?;
let accelerator = parse_word_stream(nres, TYPE_ACCELERATOR, accelerator)?;
let aux14 = parse_word_stream(nres, TYPE_AUX14, aux14)?;
let aux18 = parse_word_stream(nres, TYPE_AUX18, aux18)?;
@@ -683,6 +710,11 @@ pub fn decode_land_map(nres: &NresDocument) -> Result<LandMapDocument, TerrainFo
let payload = nres.payload(entry.id())?;
let areal_count =
usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?;
checked_count_bytes(
u64::from(meta.attr1),
u64::try_from(AREAL_PREFIX_SIZE).map_err(|_| TerrainFormatError::IntegerOverflow)?,
payload.len() as u64,
)?;
let mut cursor = Cursor::new(payload);
let mut areals = Vec::with_capacity(areal_count);
for area_index in 0..areal_count {
@@ -1072,11 +1104,11 @@ fn parse_i8x4_stream(
.collect())
}
fn parse_i16x2_stream(
fn parse_u16x2_stream(
nres: &NresDocument,
type_id: u32,
payload: &[u8],
) -> Result<Vec<[i16; 2]>, TerrainFormatError> {
) -> Result<Vec<[u16; 2]>, TerrainFormatError> {
if !payload.len().is_multiple_of(4) {
return Err(TerrainFormatError::InvalidSize {
type_id,
@@ -1088,7 +1120,7 @@ fn parse_i16x2_stream(
validate_stream(nres, type_id, 4, count)?;
let mut out = Vec::with_capacity(count);
for chunk in payload.chunks_exact(4) {
out.push([read_i16(chunk, 0)?, read_i16(chunk, 2)?]);
out.push([read_u16(chunk, 0)?, read_u16(chunk, 2)?]);
}
Ok(out)
}
@@ -1311,6 +1343,7 @@ fn parse_areal_grid(
if cell_count == 0 {
return Err(TerrainFormatError::InvalidGridSize { cells_x, cells_y });
}
checked_count_bytes(u64::from(cell_count), 2, cursor.remaining() as u64)?;
let cell_count_usize =
usize::try_from(cell_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
let mut cells = Vec::with_capacity(cell_count_usize);
@@ -1364,13 +1397,6 @@ fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, TerrainFormatError> {
Ok(u16::from_le_bytes([raw[0], raw[1]]))
}
fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, TerrainFormatError> {
let raw = bytes
.get(offset..offset + 2)
.ok_or(TerrainFormatError::IntegerOverflow)?;
Ok(i16::from_le_bytes([raw[0], raw[1]]))
}
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, TerrainFormatError> {
let raw = bytes
.get(offset..offset + 4)
@@ -1574,7 +1600,7 @@ mod tests {
}
#[test]
fn terrain_material_tag_decodes_two_sidecar_selectors() {
fn terrain_material_tag_decodes_base_overlay_and_detail_selectors() {
let mut raw_face = face([0, 1, 2], [None, None, None]);
raw_face[4..6].copy_from_slice(&0x0102_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
@@ -1583,24 +1609,40 @@ mod tests {
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
land1_selector: Some(1),
land2_selector: 2,
base_selector: 2,
overlay_selector: Some(1),
}
);
assert_eq!(
document.faces[0].material_layers().land1_selection(),
Some(TerrainMaterialSelection {
document.faces[0].material_layers().base_selection(),
TerrainMaterialSelection {
table_index: 0,
material_index: 1,
})
material_index: 2,
}
);
assert_eq!(
document.faces[0]
.material_layers()
.land2_selection()
.overlay_selection()
.expect("overlay")
.phase_key(),
0x0000_0001
);
assert_eq!(
document.faces[0]
.material_layers()
.detail_base_selection()
.phase_key(),
0x0001_0002
);
assert_eq!(
document.faces[0]
.material_layers()
.detail_overlay_selection()
.expect("detail overlay")
.phase_key(),
0x0001_0001
);
raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
@@ -1608,18 +1650,28 @@ mod tests {
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
land1_selector: None,
land2_selector: 3,
base_selector: 3,
overlay_selector: None,
}
);
assert_eq!(document.faces[0].material_layers().land1_selection(), None);
assert_eq!(
document.faces[0].material_layers().land2_selection(),
document.faces[0].material_layers().base_selection(),
TerrainMaterialSelection {
table_index: 1,
table_index: 0,
material_index: 3,
}
);
assert_eq!(
document.faces[0].material_layers().overlay_selection(),
None
);
assert_eq!(
document.faces[0]
.material_layers()
.detail_base_selection()
.phase_key(),
0x0001_0003
);
}
#[test]
@@ -1637,6 +1689,21 @@ mod tests {
assert_eq!(document.grid.compact_cells, [0x0040_0000]);
}
#[test]
fn land_map_bounds_area_and_grid_reservations_by_payload() {
let empty = build_nres(&[entry(TYPE_AREAL_MAP, u32::MAX, 0, &[])]);
let nres = decode_nres(&empty).expect("nres");
assert!(decode_land_map(&nres).is_err());
let mut payload = Vec::new();
push_areal_prefix(&mut payload, 0, 0);
push_u32(&mut payload, 65_535);
push_u32(&mut payload, 65_535);
let bytes = build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)]);
let nres = decode_nres(&bytes).expect("nres");
assert!(decode_land_map(&nres).is_err());
}
#[test]
fn land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof() {
let nres = decode_nres(&minimal_land_map_with_poly(1, true)).expect("nres");
+70 -3
View File
@@ -1,14 +1,16 @@
#![forbid(unsafe_code)]
//! Validated terrain runtime queries using XY ground coordinates and Z height.
use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
use fparkan_terrain_format::{LandMapDocument, LandMeshDocument};
use std::collections::VecDeque;
/// Terrain material-selector contract preserved from the decoded map mesh.
///
/// 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, TerrainMaterialSelection};
pub use fparkan_terrain_format::{
FullSurfaceMask, TerrainMaterialLayers, TerrainMaterialSelection,
};
/// Terrain world.
#[derive(Clone, Debug, Default)]
@@ -141,6 +143,21 @@ pub trait SurfaceQuery {
direction: [f32; 3],
mask: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError>;
/// Raycast while requiring every bit in `include` and rejecting every
/// face carrying a bit in `exclude`.
///
/// The native landscape query has both predicates. The legacy
/// [`Self::raycast`] shorthand keeps its historical include-only form;
/// callers that model a native receiver or visibility test should use
/// this method so exclusion flags cannot be mistaken for inclusion bits.
fn raycast_excluding(
&self,
origin: [f32; 3],
direction: [f32; 3],
include: FullSurfaceMask,
exclude: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError>;
}
/// Navigation query.
@@ -404,6 +421,28 @@ impl SurfaceQuery for TerrainWorld {
origin: [f32; 3],
direction: [f32; 3],
mask: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError> {
self.raycast_filtered(origin, direction, mask, FullSurfaceMask(0))
}
fn raycast_excluding(
&self,
origin: [f32; 3],
direction: [f32; 3],
include: FullSurfaceMask,
exclude: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError> {
self.raycast_filtered(origin, direction, include, exclude)
}
}
impl TerrainWorld {
fn raycast_filtered(
&self,
origin: [f32; 3],
direction: [f32; 3],
include: FullSurfaceMask,
exclude: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError> {
if self.surfaces.is_empty() {
return Err(TerrainError::Unsupported);
@@ -413,7 +452,10 @@ impl SurfaceQuery for TerrainWorld {
let mut best: Option<SurfaceHit> = None;
for index in candidates {
let triangle = &self.surfaces[index];
if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
if include.0 != 0 && triangle.mask.0 & include.0 != include.0 {
continue;
}
if triangle.mask.0 & exclude.0 != 0 {
continue;
}
let Some(distance) = triangle.raycast(origin, direction) else {
@@ -1032,6 +1074,31 @@ mod tests {
.expect("raycast"),
None
);
// Native include masks require every requested bit, while exclusion
// masks reject any matching face bit.
assert_eq!(
world
.raycast_excluding(
[0.25, 0.25, 2.0],
[0.0, 0.0, -1.0],
FullSurfaceMask(0x0000_0003),
FullSurfaceMask(0),
)
.expect("raycast include all bits"),
None
);
assert_eq!(
world
.raycast_excluding(
[0.25, 0.25, 2.0],
[0.0, 0.0, -1.0],
FullSurfaceMask(0),
FullSurfaceMask(0x0000_0001),
)
.expect("raycast exclusion"),
None
);
}
#[test]