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:
@@ -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]
|
||||
|
||||
+1085
-41
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
@@ -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
+687
-271
File diff suppressed because it is too large
Load Diff
+622
-90
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user