diff options
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/asset_mesh.rs | 6 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 1 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/ambient_op3.rs | 136 | ||||
| -rw-r--r-- | crates/fparkan-msh/src/lib.rs | 885 | ||||
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 1 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 4 | ||||
| -rw-r--r-- | docs/reference/environment-effects.md | 11 | ||||
| -rw-r--r-- | docs/reference/msh.md | 39 |
8 files changed, 1080 insertions, 3 deletions
diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index 3249012..e2a8395 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -1381,6 +1381,7 @@ mod tests { uv1: None, indices, batches, + triangle_descriptors: None, node_names: None, animation: None, } @@ -1446,6 +1447,7 @@ mod tests { ..batch(0, 0, 0) }, ], + triangle_descriptors: None, node_names: None, animation: None, }; @@ -1686,6 +1688,7 @@ mod tests { uv1: None, indices: vec![0, 1, 2, 0, 1, 2], batches: vec![batch(0, 3, 0), batch(3, 3, 0)], + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![ @@ -1745,6 +1748,7 @@ mod tests { uv1: None, indices: Vec::new(), batches: Vec::new(), + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![ @@ -1851,6 +1855,7 @@ mod tests { uv1: None, indices: vec![0, 1, 2, 0, 1, 2], batches: vec![batch(0, 3, 0), batch(3, 3, 0)], + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![AnimKey24 { @@ -1942,6 +1947,7 @@ mod tests { uv1: None, indices: vec![0, 1, 2], batches: vec![batch(0, 3, 0)], + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![AnimKey24 { diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 02d9a1b..1da3312 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -7344,6 +7344,7 @@ mod tests { uv1: None, indices: vec![], batches: vec![], + triangle_descriptors: None, node_names: None, animation: Some(fparkan_msh::ModelAnimation { keys: vec![ diff --git a/crates/fparkan-fx/src/ambient_op3.rs b/crates/fparkan-fx/src/ambient_op3.rs index 8f9a05c..1ec2973 100644 --- a/crates/fparkan-fx/src/ambient_op3.rs +++ b/crates/fparkan-fx/src/ambient_op3.rs @@ -979,6 +979,142 @@ mod tests { } #[test] + #[ignore = "requires the original GOG installation"] + fn original_tree_ray_58_matches_native_type1_geometry_samples() { + let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation"); + let bytes = std::fs::read(std::path::Path::new(&root).join("effects.rlb")) + .expect("read effects.rlb"); + let archive = fparkan_nres::decode(bytes.into(), fparkan_nres::ReadProfile::Compatible) + .expect("decode effects.rlb"); + let entry = archive.find("tree_ray_58").expect("tree_ray_58 FXID"); + let document = crate::decode_fxid( + archive + .payload(entry) + .expect("FXID payload") + .to_vec() + .into(), + ) + .expect("decode tree_ray_58"); + assert_eq!(document.header().time_mode, 2); + assert_eq!(document.header().duration_seconds, 3.0); + assert_eq!(document.commands().len(), 3); + + let parent = [ + [0.999_924_123, 0.012_319_660, 0.0, 386.673_400_879], + [-0.012_319_660, 0.999_924_123, 0.0, 603.692_321_777], + [0.0, 0.0, 1.0, 180.414_703_369], + [0.0, 0.0, 0.0, 1.0], + ]; + let center = [390.273_132, 603.647_949, 180.414_703]; + // Original GOG Effect.dll RVA 0x93D0, x87 CW 0x007F; Rust-sampled keys are supplied separately. + let native_samples = [ + ( + 0, + "defl_y_add", + [0.0, 35.0, 22.0], + [ + [386.280, 604.229_431, 179.568_024], + [386.266_876, 603.165_039, 181.261_383], + [394.266_266, 603.066_467, 181.261_383], + [394.279_4, 604.130_859, 179.568_024], + ], + ), + ( + 0, + "defl_y_add", + [20.0, -30.0, 15.0], + [ + [386.279, 604.147_4, 181.307_663], + [386.267_883, 603.247_1, 179.521_744], + [394.267_273, 603.148_5, 179.521_744], + [394.278_381, 604.048_8, 181.307_663], + ], + ), + ( + 1, + "defl_y1_add", + [0.0, 35.0, 22.0], + [ + [386.276_7, 603.963_3, 179.991_364], + [386.270_172, 603.431_152, 180.838_043], + [394.269_562, 603.332_6, 180.838_043], + [394.276_1, 603.864_746, 179.991_364], + ], + ), + ]; + for (command_index, material_name, camera_offset, expected_vertices) in native_samples { + let camera = std::array::from_fn(|axis| center[axis] + camera_offset[axis]); + let frame = evaluate_op3_type1_command( + &document.commands()[command_index], + command_index, + &parent, + 0.0, + 0.0, + camera, + ) + .expect("active tree ray quad"); + assert_eq!(frame.material.name, material_name); + for (actual, expected) in frame + .vertices + .iter() + .flatten() + .zip(expected_vertices.iter().flatten()) + { + assert!( + (actual - expected).abs() < 0.001, + "native sample vertex mismatch: {actual} vs {expected}" + ); + } + } + + for (command_index, material_name) in [(0, "defl_y_add"), (1, "defl_y1_add")] { + let command = &document.commands()[command_index]; + assert_eq!(command.opcode, FxOpcode::Op3); + assert_eq!( + read_u32(&command.raw_body, 0), + Some(TYPE1_BODY_DISCRIMINATOR) + ); + let camera_offsets = [[0.0, 35.0, 22.0], [20.0, -30.0, 15.0], [-25.0, 18.0, 10.0]]; + for elapsed in [0.0_f32, 0.375, 0.75, 1.5, 2.25, 2.999, 3.125] { + let phase = elapsed.rem_euclid(3.0) / 3.0; + for camera_offset in camera_offsets { + let camera = std::array::from_fn(|axis| center[axis] + camera_offset[axis]); + let frame = evaluate_op3_type1_command( + command, + command_index, + &parent, + elapsed, + phase, + camera, + ) + .expect("active tree ray quad"); + assert_eq!(frame.material.name, material_name); + assert!(frame.active); + let actual_center: [f32; 3] = std::array::from_fn(|axis| { + frame + .vertices + .iter() + .map(|vertex| vertex[axis]) + .sum::<f32>() + * 0.25 + }); + for axis in 0..3 { + assert!((actual_center[axis] - center[axis]).abs() < 0.001); + } + let authored_axis_edge: [f32; 3] = std::array::from_fn(|axis| { + frame.vertices[2][axis] - frame.vertices[1][axis] + }); + let native_axis_edge = [7.999_393, -0.098_557_28, 0.0]; + for axis in 0..3 { + assert!((authored_axis_edge[axis] - native_axis_edge[axis]).abs() < 0.001); + } + } + } + } + } + + #[test] fn rejects_truncated_bodies_and_non_finite_tracks_or_inputs() { let identity = [ [1.0, 0.0, 0.0, 0.0], diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs index 4b561e7..c35a3dc 100644 --- a/crates/fparkan-msh/src/lib.rs +++ b/crates/fparkan-msh/src/lib.rs @@ -23,6 +23,9 @@ pub const STREAM_UV0: u32 = 5; pub const STREAM_UV1: u32 = 18; /// Triangle index stream. pub const STREAM_INDICES: u32 = 6; +/// Optional triangle-descriptor stream. +pub const STREAM_TRIANGLE_DESCRIPTORS: u32 = 7; +const TRIANGLE_NORMAL_SCALE: f32 = 1.0 / 32_767.0; /// Animation key stream. pub const STREAM_ANIMATION_KEYS: u32 = 8; /// Node names stream. @@ -102,6 +105,8 @@ pub struct ModelAsset { pub indices: Vec<u16>, /// Draw batches. pub batches: Vec<Batch>, + /// Optional type-7 triangle descriptors. + pub triangle_descriptors: Option<Vec<TriangleDescriptor>>, /// Optional decoded node names. pub node_names: Option<Vec<Option<String>>>, /// Optional decoded node-animation streams. @@ -201,9 +206,48 @@ pub struct Batch { pub base_vertex: u32, } -/// Preserved triangle descriptor stream marker. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TriangleDescriptor; +/// One 16-byte type-7 triangle descriptor. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TriangleDescriptor { + /// Triangle mask flags at byte offset `+0`. + pub tri_flags: u16, + /// Opaque 16-bit words from offsets `+2`, `+4`, `+6` and `+14`. + pub opaque: [u16; 4], + /// Signed packed plane normal at byte offsets `+8`, `+10` and `+12`. + pub normal: [i16; 3], +} + +/// Include-all and exclude-any masks used by the native MSH query. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SegmentQueryMasks { + /// Node38 header bits that must all be set. + pub node_include: u16, + /// Node38 header bits that must all be clear. + pub node_exclude: u16, + /// Batch20 leading-DWORD bits that must all be set. + pub batch_include: u32, + /// Batch20 leading-DWORD bits that must all be clear. + pub batch_exclude: u32, + /// Type-7 triangle bits that must all be set. + pub triangle_include: u16, + /// Type-7 triangle bits that must all be clear. + pub triangle_exclude: u16, +} + +/// One accepted segment hit from the native MSH triangle-query path. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SegmentHit { + /// Hit position in the same coordinate frame as the input segment. + pub position: [f32; 3], + /// Squared distance from the input segment's first endpoint. + pub distance_squared: f32, + /// Global Batch20 index. + pub batch_index: usize, + /// Global type-7 descriptor index. + pub triangle_descriptor_index: usize, + /// Global Res3 position indices of the hit triangle. + pub vertex_indices: [usize; 3], +} /// Vertex stream view. #[derive(Clone, Debug, PartialEq)] @@ -685,6 +729,287 @@ pub fn draw_batches(model: &ModelAsset, slot: SlotId) -> Result<&[Batch], MshErr }) } +/// Queries a segment against one slot using the native Node38, Batch20 and +/// type-7 include-all/exclude-any masks. +/// +/// The first endpoint is the native forward direction. Batch bit `0x0002` +/// retries the plane test in reverse only when the forward plane test misses. +/// The returned distance is always measured from the original first endpoint. +/// +/// # Errors +/// +/// Returns [`MshError`] when type 7 is absent, the model does not use Node38, +/// or the selected slot's descriptor, batch, index or vertex ranges are invalid. +pub fn query_slot_segment( + model: &ModelAsset, + node: NodeId, + slot: SlotId, + endpoints: [[f32; 3]; 2], + masks: SegmentQueryMasks, +) -> Result<Option<SegmentHit>, MshError> { + let descriptors = model + .triangle_descriptors + .as_deref() + .ok_or(MshError::MissingStream { + type_id: STREAM_TRIANGLE_DESCRIPTORS, + label: "Res7", + })?; + if model.node_stride != 38 { + return Err(MshError::InvalidGeometry(format!( + "segment query requires Node38 metadata, got node stride {}", + model.node_stride + ))); + } + if !endpoints.iter().flatten().all(|value| value.is_finite()) { + return Err(MshError::InvalidGeometry( + "segment endpoints must be finite".to_string(), + )); + } + let node_flags = node38_metadata(model, node) + .ok_or_else(|| MshError::InvalidGeometry("invalid Node38 metadata".to_string()))? + .header_flags; + let slot_index = usize::try_from(slot.0).map_err(|_| MshError::SlotOutOfBounds { + slot: slot.0, + slot_count: model.slots.len(), + })?; + let slot_ref = model + .slots + .get(slot_index) + .ok_or(MshError::SlotOutOfBounds { + slot: slot.0, + slot_count: model.slots.len(), + })?; + let batches = draw_batches(model, slot)?; + let descriptor_start = usize::from(slot_ref.tri_start); + let descriptor_end = descriptor_start + .checked_add(usize::from(slot_ref.tri_count)) + .ok_or_else(|| MshError::InvalidGeometry("type-7 range overflow".to_string()))?; + if descriptor_end > descriptors.len() { + return Err(MshError::InvalidGeometry(format!( + "slot {slot_index} type-7 range {descriptor_start}..{descriptor_end} exceeds {} descriptors", + descriptors.len() + ))); + } + + let mut face_count = 0usize; + for (local_batch, batch) in batches.iter().enumerate() { + if batch.index_count % 3 != 0 { + return Err(MshError::InvalidGeometry(format!( + "batch {} index count {} is not triangle-aligned", + usize::from(slot_ref.batch_start) + local_batch, + batch.index_count + ))); + } + let start = usize::try_from(batch.index_start) + .map_err(|_| MshError::InvalidGeometry("batch index range overflow".to_string()))?; + let end = start + .checked_add(usize::from(batch.index_count)) + .ok_or_else(|| MshError::InvalidGeometry("batch index range overflow".to_string()))?; + if end > model.indices.len() { + return Err(MshError::InvalidGeometry(format!( + "batch {} index range {start}..{end} exceeds {} indices", + usize::from(slot_ref.batch_start) + local_batch, + model.indices.len() + ))); + } + face_count = face_count + .checked_add(usize::from(batch.index_count) / 3) + .ok_or_else(|| MshError::InvalidGeometry("slot triangle count overflow".to_string()))?; + } + if face_count != usize::from(slot_ref.tri_count) { + return Err(MshError::InvalidGeometry(format!( + "slot {slot_index} declares {} type-7 descriptors but its batches contain {face_count} triangles", + slot_ref.tri_count + ))); + } + if !mask_matches( + u32::from(node_flags), + u32::from(masks.node_include), + u32::from(masks.node_exclude), + ) { + return Ok(None); + } + + let mut best = None; + let mut descriptor_cursor = descriptor_start; + for (local_batch, batch) in batches.iter().enumerate() { + let batch_index = usize::from(slot_ref.batch_start) + local_batch; + let batch_flags = u32::from(batch.batch_flags) | (u32::from(batch.material_index_hi) << 16); + let batch_face_count = usize::from(batch.index_count) / 3; + if mask_matches(batch_flags, masks.batch_include, masks.batch_exclude) { + for local_face in 0..batch_face_count { + let triangle_descriptor_index = descriptor_cursor + local_face; + let descriptor = descriptors[triangle_descriptor_index]; + if !mask_matches( + u32::from(descriptor.tri_flags), + u32::from(masks.triangle_include), + u32::from(masks.triangle_exclude), + ) { + continue; + } + let index_start = usize::try_from(batch.index_start) + .expect("validated batch index range") + + local_face * 3; + let base_vertex = usize::try_from(batch.base_vertex) + .map_err(|_| MshError::InvalidGeometry("base vertex overflow".to_string()))?; + let mut vertex_indices = [0usize; 3]; + for (triangle_corner, index) in vertex_indices.iter_mut().enumerate() { + let raw_index = usize::from(model.indices[index_start + triangle_corner]); + *index = base_vertex.checked_add(raw_index).ok_or_else(|| { + MshError::InvalidGeometry("resolved vertex index overflow".to_string()) + })?; + if *index >= model.positions.len() { + return Err(MshError::VertexIndexOutOfBounds { + batch: batch_index, + vertex: u64::try_from(*index).unwrap_or(u64::MAX), + position_count: model.positions.len(), + }); + } + } + let triangle = vertex_indices.map(|index| model.positions[index]); + if !triangle.iter().flatten().all(|value| value.is_finite()) { + return Err(MshError::InvalidGeometry(format!( + "triangle descriptor {triangle_descriptor_index} has a non-finite position" + ))); + } + let normal = descriptor + .normal + .map(|value| f32::from(value) * TRIANGLE_NORMAL_SCALE); + if normal == [0.0; 3] { + continue; + } + let forward = + native_segment_plane_hit(normal, triangle[0], endpoints[0], endpoints[1])?; + let position = match forward { + Some(position) => Some(position), + None if batch.batch_flags & 0x0002 != 0 => { + native_segment_plane_hit(normal, triangle[0], endpoints[1], endpoints[0])? + } + None => None, + }; + let Some(position) = position else { + continue; + }; + if !native_point_in_triangle(normal, triangle, position)? { + continue; + } + let offset = [ + position[0] - endpoints[0][0], + position[1] - endpoints[0][1], + position[2] - endpoints[0][2], + ]; + let distance_squared = dot3(offset, offset); + if !distance_squared.is_finite() { + return Err(MshError::InvalidGeometry( + "segment hit distance is non-finite".to_string(), + )); + } + if best.is_none_or(|hit: SegmentHit| distance_squared <= hit.distance_squared) { + best = Some(SegmentHit { + position, + distance_squared, + batch_index, + triangle_descriptor_index, + vertex_indices, + }); + } + } + } + descriptor_cursor += batch_face_count; + } + Ok(best) +} + +fn mask_matches(flags: u32, include: u32, exclude: u32) -> bool { + flags & include == include && flags & exclude == 0 +} + +fn native_segment_plane_hit( + normal: [f32; 3], + anchor: [f32; 3], + start: [f32; 3], + end: [f32; 3], +) -> Result<Option<[f32; 3]>, MshError> { + let direction = [end[0] - start[0], end[1] - start[1], end[2] - start[2]]; + let denominator = dot3(normal, direction); + let start_distance = dot3(normal, start) - dot3(normal, anchor); + let end_distance = start_distance + denominator; + if !denominator.is_finite() || !start_distance.is_finite() || !end_distance.is_finite() { + return Err(MshError::InvalidGeometry( + "segment plane calculation is non-finite".to_string(), + )); + } + if denominator >= 0.0 || start_distance <= 0.0 || end_distance > 0.0 { + return Ok(None); + } + let ratio = start_distance / denominator; + let position = [ + start[0] - direction[0] * ratio, + start[1] - direction[1] * ratio, + start[2] - direction[2] * ratio, + ]; + if !position.iter().all(|value| value.is_finite()) { + return Err(MshError::InvalidGeometry( + "segment intersection is non-finite".to_string(), + )); + } + Ok(Some(position)) +} + +fn native_point_in_triangle( + normal: [f32; 3], + triangle: [[f32; 3]; 3], + point: [f32; 3], +) -> Result<bool, MshError> { + let absolute = normal.map(f32::abs); + let xy_max = if absolute[0] < absolute[1] { + absolute[1] + } else { + absolute[0] + }; + let dominant_axis = if absolute[2] <= xy_max { + if absolute[0] < absolute[1] { + 1 + } else { + 0 + } + } else { + 2 + }; + for index in 0..3 { + let previous = triangle[(index + 2) % 3]; + let current = triangle[index]; + let relative = [ + point[0] - previous[0], + point[1] - previous[1], + point[2] - previous[2], + ]; + let edge = [ + current[0] - previous[0], + current[1] - previous[1], + current[2] - previous[2], + ]; + let side = match dominant_axis { + 0 => (relative[2] * edge[1] + relative[1] * -edge[2]) * normal[0], + 1 => (-edge[0] * relative[2] + relative[0] * edge[2]) * normal[1], + _ => (relative[1] * edge[0] - relative[0] * edge[1]) * normal[2], + }; + if !side.is_finite() { + return Err(MshError::InvalidGeometry( + "triangle edge calculation is non-finite".to_string(), + )); + } + if side < 0.0 { + return Ok(false); + } + } + Ok(true) +} + +fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 { + left[0] * right[0] + left[1] * right[1] + left[2] * right[2] +} + impl MshDocument { /// Returns original stream descriptors. #[must_use] @@ -791,6 +1116,9 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError> let uv1 = read_optional_stream(document, STREAM_UV1)? .map(|raw| parse_secondary_uv_stream(raw, positions.len())) .transpose()?; + let triangle_descriptors = read_optional_triangle_descriptors(document)? + .map(|raw| parse_triangle_descriptors(&raw.bytes)) + .transpose()?; let node_names = read_optional_stream(document, STREAM_NAMES)? .map(|raw| parse_res10_names(&raw.bytes, node_count)) .transpose()?; @@ -809,6 +1137,7 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError> uv1, indices, batches, + triangle_descriptors, node_names, animation, }) @@ -879,6 +1208,28 @@ fn read_optional_stream( })) } +fn read_optional_triangle_descriptors( + document: &NresDocument, +) -> Result<Option<RawStream>, MshError> { + let mut entries = document + .entries() + .iter() + .filter(|entry| entry.meta().type_id == STREAM_TRIANGLE_DESCRIPTORS); + let Some(entry) = entries.next() else { + return Ok(None); + }; + if entries.next().is_some() { + return Err(MshError::DuplicateStream { + type_id: STREAM_TRIANGLE_DESCRIPTORS, + label: "Res7", + }); + } + Ok(Some(RawStream { + attributes: attributes(entry.meta()), + bytes: document.payload(entry.id())?.to_vec(), + })) +} + fn parse_slots(data: &[u8]) -> Result<Vec<Slot>, MshError> { if data.len() < 0x8C { return Err(MshError::InvalidGeometry(format!( @@ -997,6 +1348,30 @@ fn parse_batches(data: &[u8]) -> Result<Vec<Batch>, MshError> { Ok(out) } +fn parse_triangle_descriptors(data: &[u8]) -> Result<Vec<TriangleDescriptor>, MshError> { + if !data.len().is_multiple_of(16) { + return Err(invalid_resource_size("Res7", data.len(), 16)); + } + let mut out = Vec::with_capacity(data.len() / 16); + for offset in (0..data.len()).step_by(16) { + out.push(TriangleDescriptor { + tri_flags: read_u16_required(data, offset)?, + opaque: [ + read_u16_required(data, offset + 2)?, + read_u16_required(data, offset + 4)?, + read_u16_required(data, offset + 6)?, + read_u16_required(data, offset + 14)?, + ], + normal: [ + read_i16_required(data, offset + 8)?, + read_i16_required(data, offset + 10)?, + read_i16_required(data, offset + 12)?, + ], + }); + } + Ok(out) +} + fn parse_u16_array(data: &[u8], label: &'static str) -> Result<Vec<u16>, MshError> { if !data.len().is_multiple_of(2) { return Err(invalid_resource_size(label, data.len(), 2)); @@ -1212,6 +1587,12 @@ fn read_u16_required(bytes: &[u8], offset: usize) -> Result<u16, MshError> { Ok(u16::from_le_bytes(arr)) } +fn read_i16_required(bytes: &[u8], offset: usize) -> Result<i16, MshError> { + Ok(i16::from_le_bytes( + read_u16_required(bytes, offset)?.to_le_bytes(), + )) +} + fn read_i8(bytes: &[u8], offset: usize) -> Result<i8, MshError> { let byte = bytes .get(offset) @@ -1259,6 +1640,352 @@ mod tests { assert_eq!(model.node_stride, 38); assert_eq!(model.node_count, 0); assert!(model.slots.is_empty()); + assert_eq!(model.triangle_descriptors, None); + } + + #[test] + fn type7_decodes_optional_records_and_keeps_opaque_words() { + let mut raw = Vec::new(); + push_u16(&mut raw, 0x3456); + push_u16(&mut raw, 0x1111); + push_u16(&mut raw, 0x2222); + push_u16(&mut raw, 0x3333); + raw.extend_from_slice(&(-1_i16).to_le_bytes()); + raw.extend_from_slice(&i16::MIN.to_le_bytes()); + raw.extend_from_slice(&i16::MAX.to_le_bytes()); + push_u16(&mut raw, 0x4444); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7", &raw), + ])) + .expect("nested NRes"); + let msh = decode_msh(&document).expect("optional type7 stream"); + let model = validate_msh(&msh).expect("model"); + + assert_eq!( + model.triangle_descriptors, + Some(vec![TriangleDescriptor { + tri_flags: 0x3456, + opaque: [0x1111, 0x2222, 0x3333, 0x4444], + normal: [-1, i16::MIN, i16::MAX], + }]) + ); + let preserved = msh.preserved_streams().expect("preserved streams"); + assert_eq!( + preserved + .iter() + .find(|stream| stream.type_id == STREAM_TRIANGLE_DESCRIPTORS) + .unwrap() + .bytes + .as_ref(), + raw + ); + } + + #[test] + fn malformed_or_duplicate_optional_type7_stream_is_an_error() { + for entries in [ + vec![ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7", &[0; 15]), + ], + vec![ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7a", &[0; 16]), + stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7b", &[0; 16]), + ], + ] { + let document = decode_nested(&build_nres(&entries)).expect("nested NRes"); + let msh = decode_msh(&document).expect("required streams"); + assert!(validate_msh(&msh).is_err()); + } + } + + #[test] + fn segment_query_uses_native_one_sided_edge_and_endpoint_rules() { + let model = segment_model(&[(0, 0)], &[0]); + let hit = query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("forward segment") + .expect("front-to-back hit"); + assert_eq!(hit.position, [0.0, 0.0, 0.0]); + assert_eq!(hit.distance_squared, 1.0); + assert_eq!(hit.vertex_indices, [0, 1, 2]); + + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, -1.0, 0.0], [0.0, 1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("reverse segment") + .is_none()); + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, 0.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("endpoint on plane") + .is_some()); + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 0.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("start on plane") + .is_none()); + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, -1.0], [0.0, -1.0, -1.0]], + SegmentQueryMasks::default(), + ) + .expect("segment crossing triangle edge") + .is_some()); + + let two_sided = segment_model(&[(2, 0)], &[0]); + assert!(query_slot_segment( + &two_sided, + NodeId(0), + SlotId(0), + [[0.0, -1.0, 0.0], [0.0, 1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("two-sided reverse retry") + .is_some()); + } + + #[test] + fn segment_query_skips_triangles_with_zero_packed_normals() { + let mut model = segment_model(&[(0, 0)], &[0]); + model + .triangle_descriptors + .as_mut() + .expect("type-7 descriptors")[0] + .normal = [0; 3]; + + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("zero normal is a missed triangle") + .is_none()); + } + + #[test] + fn segment_query_applies_native_masks_and_advances_filtered_descriptors() { + let mut model = segment_model(&[(0x0200, 1), (0, 1), (0, 1)], &[0, 0x0020, 0x0004]); + model + .triangle_descriptors + .as_mut() + .expect("type-7 descriptors") + .insert( + 0, + TriangleDescriptor { + tri_flags: 0, + opaque: [0; 4], + normal: [0, i16::MAX, 0], + }, + ); + model.slots[0].tri_start = 1; + let masks = SegmentQueryMasks { + node_include: 0x0004, + node_exclude: 0, + batch_include: 0x0001_0000, + batch_exclude: 0x0000_0200, + triangle_include: 0x0004, + triangle_exclude: 0x0020, + }; + let hit = query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + masks, + ) + .expect("native masks") + .expect("third descriptor survives"); + assert_eq!(hit.batch_index, 2); + assert_eq!(hit.triangle_descriptor_index, 3); + assert_eq!(hit.vertex_indices, [6, 7, 8]); + + for masks in [ + SegmentQueryMasks { + node_include: 0x0008, + ..masks + }, + SegmentQueryMasks { + node_exclude: 0x0004, + ..masks + }, + SegmentQueryMasks { + batch_include: 0x0002_0000, + ..masks + }, + SegmentQueryMasks { + triangle_exclude: 0x0004, + ..masks + }, + ] { + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + masks, + ) + .expect("filtered query") + .is_none()); + } + } + + #[test] + fn segment_query_replaces_equal_distance_hits_with_the_later_candidate() { + let model = segment_model(&[(0, 0), (0, 0)], &[0, 0]); + let hit = query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("equal-distance candidates") + .expect("hit"); + assert_eq!(hit.distance_squared, 1.0); + assert_eq!(hit.batch_index, 1); + assert_eq!(hit.triangle_descriptor_index, 1); + } + + #[test] + fn native_point_in_triangle_projects_on_each_dominant_normal_axis() { + for (normal, triangle, edge, outside) in [ + ( + [1.0, 0.0, 0.0], + [[0.0, -1.0, -1.0], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]], + [0.0, 0.0, -1.0], + [0.0, 2.0, 0.0], + ), + ( + [0.0, 1.0, 0.0], + [[-1.0, 0.0, -1.0], [0.0, 0.0, 1.0], [1.0, 0.0, -1.0]], + [0.0, 0.0, -1.0], + [0.0, 0.0, 2.0], + ), + ( + [0.0, 0.0, 1.0], + [[-1.0, -1.0, 0.0], [1.0, -1.0, 0.0], [0.0, 1.0, 0.0]], + [0.0, -1.0, 0.0], + [2.0, 0.0, 0.0], + ), + ] { + assert!(native_point_in_triangle(normal, triangle, [0.0; 3]).expect("inside")); + assert!(native_point_in_triangle(normal, triangle, edge).expect("edge")); + assert!(!native_point_in_triangle(normal, triangle, outside).expect("outside")); + } + } + + #[test] + fn segment_query_reports_missing_type7_and_invalid_metadata_or_ranges() { + let mut model = segment_model(&[(0, 0)], &[0]); + model.triangle_descriptors = None; + assert!(matches!( + query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ), + Err(MshError::MissingStream { + type_id: STREAM_TRIANGLE_DESCRIPTORS, + .. + }) + )); + + let mut model = segment_model(&[(0, 0)], &[0]); + model.node_stride = 24; + assert!(matches!( + query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ), + Err(MshError::InvalidGeometry(_)) + )); + + let mut model = segment_model(&[(0, 0)], &[0]); + model.slots[0].tri_start = 1; + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .is_err()); + + let mut model = segment_model(&[(0, 0)], &[0]); + model.slots[0].batch_count = 2; + assert!(matches!( + query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ), + Err(MshError::BatchRangeOutOfBounds { .. }) + )); + + let mut model = segment_model(&[(0, 0)], &[0]); + model.batches[0].index_count = 4; + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .is_err()); + + let mut model = segment_model(&[(0, 0)], &[0]); + model.batches[0].index_start = 3; + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .is_err()); } #[test] @@ -1537,6 +2264,7 @@ mod tests { uv1: None, indices: Vec::new(), batches: Vec::new(), + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![ @@ -1598,6 +2326,7 @@ mod tests { uv1: None, indices: Vec::new(), batches: Vec::new(), + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![ @@ -1662,6 +2391,7 @@ mod tests { uv1: None, indices: Vec::new(), batches: Vec::new(), + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![ @@ -2420,6 +3150,155 @@ mod tests { track.sample(AnimationTime(frame as f32)) } + #[test] + #[ignore = "requires licensed corpus"] + fn factory_wall_door_and_reversed_segments_match_native_query() { + let root = corpus_root("IS"); + let archive_path = files_under(&root) + .into_iter() + .find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("fortif.rlb")) + }) + .expect("fortif.rlb in licensed corpus"); + let archive_bytes = std::fs::read(&archive_path).expect("fortif.rlb bytes"); + let archive = fparkan_nres::decode( + Arc::from(archive_bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) + .expect("fortif.rlb archive"); + let entry = archive + .entries() + .iter() + .find(|entry| entry.name_bytes().eq_ignore_ascii_case(b"fr_l_plant.msh")) + .expect("plant factory MSH"); + let nested = fparkan_nres::decode( + Arc::from( + archive + .payload(entry.id()) + .expect("MSH payload") + .to_vec() + .into_boxed_slice(), + ), + ReadProfile::Compatible, + ) + .expect("nested plant MSH"); + let msh = decode_msh(&nested).expect("plant MSH document"); + let model = validate_msh(&msh).expect("plant MSH model"); + let slot = selected_slot(&model, NodeId(1), Lod(0), Group(0)).expect("Node 1 slot"); + assert_eq!(slot, SlotId(0)); + let hierarchy = node38_fallback_hierarchy(&model).expect("Node38 poses"); + let pose = hierarchy.poses[1]; + assert_eq!(pose.rotation, [0.0, 0.0, 0.0, 1.0]); + + let masks = SegmentQueryMasks { + node_include: 0, + node_exclude: 0, + batch_include: 0, + batch_exclude: 0x0208, + triangle_include: 0, + triangle_exclude: 0x0024, + }; + let local = |point: [f32; 3]| { + [ + point[0] - pose.translation[0], + point[1] - pose.translation[1], + point[2] - pose.translation[2], + ] + }; + let wall = query_slot_segment( + &model, + NodeId(1), + slot, + [local([-10.5, 22.16, 2.5]), local([-10.5, 19.16, 2.5])], + masks, + ) + .expect("wall query") + .expect("solid wall hit"); + assert_eq!(wall.batch_index, 5); + assert_eq!(wall.triangle_descriptor_index, 62); + assert_eq!(wall.distance_squared, 4.0); + assert!(query_slot_segment( + &model, + NodeId(1), + slot, + [local([0.5, 22.16, 2.5]), local([0.5, 19.16, 2.5])], + masks, + ) + .expect("door query") + .is_none()); + assert!(query_slot_segment( + &model, + NodeId(1), + slot, + [local([-10.5, 19.16, 2.5]), local([-10.5, 22.16, 2.5])], + masks, + ) + .expect("reversed wall query") + .is_none()); + } + + fn segment_model(batch_fields: &[(u16, u16)], tri_flags: &[u16]) -> ModelAsset { + assert_eq!(batch_fields.len(), tri_flags.len()); + let mut nodes_raw = node38([u16::MAX; 15]); + nodes_raw[0..2].copy_from_slice(&0x0004_u16.to_le_bytes()); + let mut positions = Vec::with_capacity(batch_fields.len() * 3); + let mut indices = Vec::with_capacity(batch_fields.len() * 3); + let mut batches = Vec::with_capacity(batch_fields.len()); + for index in 0..batch_fields.len() { + positions.extend_from_slice(&[[-1.0, 0.0, -1.0], [0.0, 0.0, 1.0], [1.0, 0.0, -1.0]]); + indices.extend_from_slice(&[0, 1, 2]); + batches.push(Batch { + batch_flags: batch_fields[index].0, + material_index_hi: batch_fields[index].1, + material_index: 0, + lightmap_index: 0, + local_batch_index: u16::try_from(index).expect("local batch index"), + index_count: 3, + index_start: u32::try_from(index * 3).expect("index start"), + vertex_count: 3, + base_vertex: u32::try_from(index * 3).expect("base vertex"), + }); + } + ModelAsset { + node_stride: 38, + node_count: 1, + nodes_raw, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 1.0, + slots: vec![Slot { + tri_start: 0, + tri_count: u16::try_from(tri_flags.len()).expect("triangle count"), + batch_start: 0, + batch_count: u16::try_from(batch_fields.len()).expect("batch count"), + aabb_min: [-1.0, 0.0, -1.0], + aabb_max: [1.0, 0.0, 1.0], + sphere_center: [0.0; 3], + sphere_radius: 1.0, + opaque: [0; 5], + }], + positions, + normals: None, + uv0: None, + uv1: None, + indices, + batches, + node_names: None, + animation: None, + triangle_descriptors: Some( + tri_flags + .iter() + .map(|tri_flags| TriangleDescriptor { + tri_flags: *tri_flags, + opaque: [0; 4], + normal: [0, i16::MAX, 0], + }) + .collect(), + ), + } + } + fn decode_nested(bytes: &[u8]) -> Result<NresDocument, NresError> { fparkan_nres::decode( Arc::from(bytes.to_vec().into_boxed_slice()), diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 5e352fc..1bacc54 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -6810,6 +6810,7 @@ mod tests { uv1: None, indices: Vec::new(), batches: Vec::new(), + triangle_descriptors: None, node_names: None, animation: None, } diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index a31b561..68a01d0 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -14,6 +14,10 @@ state transitions и окончательный порядок части transp рельефом. Она пока не проверяет стены и проёмы строений; эти ограничения не подтверждают поведение стратегической камеры или камеры робота оригинала. +Локальный MSH-запрос `query_slot_segment` сверён с оригиналом на фабричной +стене и дверном проёме. Мировое преобразование, фильтры категории и +подключение к свободной камере остаются открытыми ([MSH](../reference/msh.md)). + Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state log на минимальных сценах с одним типом материала. diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md index 46a456a..799a969 100644 --- a/docs/reference/environment-effects.md +++ b/docs/reference/environment-effects.md @@ -328,6 +328,17 @@ RVA `0x93D0`, ветка mode 1 с `0x9739`, сборка столбцов `0x97 двух столбцов могут немного отличаться от единицы из-за приближённой нормализации выбранного `g_FastProc`; порядок осей от этого не меняется. +У сооружения `s_tree_58` в миссии «Конструктор» FXID `tree_ray_58` задаёт два +слоя жёлтого луча: команды Op3 mode 1 с ресурсами `defl_y_add` и `defl_y1_add`. +Их геометрию сверили с CPU-вызовом GOG `Effect.dll` `0x93D0`. На семи временных +фазах и трёх направлениях камеры для каждой команды (42 выборки) вершины +совпали с Rust evaluator с максимальной покомпонентной разницей `0.0001373291`. +Оракул получает уже выбранные Rust позицию, ось и масштаб, а world-camera +переводится в `cameraLocal` тем же component-wise projection, что и native +`FastProc + 0x68`. Поэтому это подтверждает только геометрию Type 1 helper-а на +этих входах: обновление и расписание эффекта, UV/material и пиксельное +совпадение здесь не проверялись. + ## Частицы Op7 `fparkan-fx::op7` вычисляет фиксированный набор частиц Op7 по body команды, diff --git a/docs/reference/msh.md b/docs/reference/msh.md index a28514d..7c6eca7 100644 --- a/docs/reference/msh.md +++ b/docs/reference/msh.md @@ -154,6 +154,45 @@ Byte `material_index` выбирает строку WEAR; high word at `+0x02` Ñ индекс через `base_vertex` попадает в position stream. `vertex_count` и `local_batch_index` доступны renderer для исходного draw contract. +## Type 7 и сегментный запрос + +Type 7 необязателен для обычного MSH preview. Если stream есть, каждые 16 bytes +описывают одну грань: `tri_flags` находится по `+0`, четыре неинтерпретируемых +слова — по `+2`, `+4`, `+6` и `+14`, а signed `normal[3]` — по `+8`, `+0xA` и +`+0xC`. `ModelAsset.triangle_descriptors` сохраняет декодированную таблицу как +`Option`; отсутствие Type 7 не делает статическую модель недействительной. + +`query_slot_segment` использует Node38 header mask, Batch20 leading DWORD +`batch_flags | (material_index_hi << 16)` и `tri_flags`. Для каждого поля +include mask требует все установленные биты, exclude mask пропускает запись при +любом совпавшем бите. Descriptor cursor начинается с `Slot68.tri_start` и +сдвигается на `Batch20.index_count / 3` после каждой записи, даже если batch +или грань отфильтрованы. Индексы грани читаются из type 6 с учётом +`base_vertex`. + +Правило восстановлено для GOG-бинарей `AniMesh.dll` +(`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`) и +`Ngi32.dll` (`bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5`). +Callback `AniMesh.dll+0x11090` переводит signed type-7 normal множителем +`1 / 32767`; функция `AniMesh.dll+0x11EB0` записывает этот scale в глобальное +поле. Callback вызывает FastProc slot `+0xB4`: в этой сборке он указывает на +`Ngi32.dll+0x1D560`. После обычной загрузки DLL порог `Ngi32.dll+0x37224` +равен нулю, поэтому plane test принимает только переход от положительного +расстояния к неположительному (`dot(normal, end - start) < 0`, расстояние в +начале `> 0`, в конце `<= 0`). `Batch20.batch_flags & 0x0002` разрешает повторный +plane test в обратном направлении, только если прямой plane test не пересёк +плоскость. Пересечение проверяется export ordinal 202 +(`Ngi32.dll+0x1E60`, `mrnPointInPoly`): проекция выбирает ось с наибольшим по +модулю normal component, а точка проходит грань, пока ни одно edge expression +не меньше нуля. Равенство нулю принимается, поэтому попадание на ребро или +вершину считается попаданием. Поздний кандидат с тем же squared distance +заменяет предыдущий; расстояние считается от исходного первого endpoint. + +Native helper получает точку пересечения через `RCPSS` и один шаг уточнения; +Rust использует обычное деление `f32`, поэтому последние биты координат могут +отличаться. Игнорируемый тест с ресурсами проверяет исходную стену, дверной +проём и обратное направление на `fortif.rlb/fr_l_plant.msh`. + ## Сторона грани в цветовом проходе В цветном проходе `Terrain.dll`, функция `0x100296F0`, сравнивает камеру с |
