fix(runtime): follow linked nodes after building helper hits
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m16s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m16s
This commit is contained in:
6 files changed
+694
-47
No files matched your search
Generated
+1
@@ -637,6 +637,7 @@ dependencies = [
|
||||
name = "fparkan-runtime"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fparkan-animation",
|
||||
"fparkan-assets",
|
||||
"fparkan-msh",
|
||||
"fparkan-path",
|
||||
|
||||
@@ -200,7 +200,13 @@ pub struct Batch {
|
||||
pub material_index: u8,
|
||||
/// Lightmap selector at byte offset `+0x05`.
|
||||
pub lightmap_index: u8,
|
||||
/// Local batch index at byte offset `+0x06`.
|
||||
/// Raw word at byte offset `+0x06`.
|
||||
///
|
||||
/// In the native building-collision helper retry, this word together with
|
||||
/// the model's base index selects the next linked Node38 root. For a
|
||||
/// standalone `ModelAsset`, that base is zero, so the link is this raw
|
||||
/// value. Keep the decoded field lossless; its meaning depends on the
|
||||
/// query path and batch flags.
|
||||
pub local_batch_index: u16,
|
||||
/// Index count.
|
||||
pub index_count: u16,
|
||||
|
||||
@@ -17,3 +17,6 @@ fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-script = { path = "../fparkan-script" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
fparkan-world = { path = "../fparkan-world" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-animation = { path = "../fparkan-animation" }
|
||||
@@ -1,6 +1,8 @@
|
||||
//! World-space segment queries over selected building MSH nodes and slots.
|
||||
|
||||
use fparkan_msh::{query_slot_segment, ModelAsset, MshError, NodeId, SegmentQueryMasks, SlotId};
|
||||
use fparkan_msh::{
|
||||
node38_metadata, query_slot_segment, ModelAsset, MshError, NodeId, SegmentQueryMasks, SlotId,
|
||||
};
|
||||
use fparkan_render::RawCameraTransform;
|
||||
|
||||
/// One selected MSH node/slot and its affine transform into world space.
|
||||
@@ -41,7 +43,9 @@ const MAX_INTERSECTION_VISITS: usize = 200;
|
||||
/// query excludes `0x0200` while keeping bit `0x0008` visible to the building
|
||||
/// retry rule; triangle flags use `0/0x0024`. A nearest bit-8 batch hit restarts
|
||||
/// the query from that world-space point toward the original endpoint, just as
|
||||
/// the native building camera does.
|
||||
/// the native building camera does. Each helper's Batch20 `local_batch_index`
|
||||
/// selects the next Node38 root; that retry visits its eligible descendants
|
||||
/// child-before-self and queries only nodes with supplied geometry bindings.
|
||||
pub fn query_building_segment_world(
|
||||
model: &ModelAsset,
|
||||
nodes: &[BuildingMeshNode],
|
||||
@@ -72,10 +76,25 @@ pub fn query_building_segment_world(
|
||||
let original_start = world_segment[0];
|
||||
let original_end = world_segment[1];
|
||||
let mut segment_start = original_start;
|
||||
let mut linked_root = None;
|
||||
let mut intersection_visits = 0;
|
||||
loop {
|
||||
let mut nearest = None;
|
||||
for node in &prepared_nodes {
|
||||
let query_nodes = if let Some(root) = linked_root {
|
||||
let order = linked_node_postorder(model, root)?;
|
||||
let mut ordered = Vec::new();
|
||||
for node_id in order {
|
||||
ordered.extend(
|
||||
prepared_nodes
|
||||
.iter()
|
||||
.filter(|node| node.source.node == node_id),
|
||||
);
|
||||
}
|
||||
ordered
|
||||
} else {
|
||||
prepared_nodes.iter().collect()
|
||||
};
|
||||
for node in query_nodes {
|
||||
let local_segment = [
|
||||
transform_point(node.node_from_world, segment_start)
|
||||
.ok_or_else(|| invalid_transformed_point(node.source.node, "world start"))?,
|
||||
@@ -155,8 +174,111 @@ pub fn query_building_segment_world(
|
||||
if batch_flags & 0x0008 == 0 {
|
||||
return Ok(Some(nearest));
|
||||
}
|
||||
segment_start = nearest.position;
|
||||
let batch = model.batches.get(nearest.batch_index).ok_or_else(|| {
|
||||
MshError::InvalidGeometry(format!(
|
||||
"building query returned missing batch {}",
|
||||
nearest.batch_index
|
||||
))
|
||||
})?;
|
||||
if batch.local_batch_index == u16::MAX {
|
||||
return Err(MshError::InvalidGeometry(format!(
|
||||
"building helper batch {} has the sentinel linked-node key",
|
||||
nearest.batch_index
|
||||
)));
|
||||
}
|
||||
let linked_node = NodeId(u32::from(batch.local_batch_index));
|
||||
if usize::try_from(linked_node.0).unwrap_or(usize::MAX) >= model.node_count {
|
||||
return Err(MshError::InvalidGeometry(format!(
|
||||
"building helper batch {} has linked node {} outside Node38 count {}",
|
||||
nearest.batch_index, linked_node.0, model.node_count
|
||||
)));
|
||||
}
|
||||
segment_start = nearest.position;
|
||||
linked_root = Some(linked_node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the native child-before-self query order rooted at a portal link.
|
||||
///
|
||||
/// Node metadata is traversed even when the caller has no selected geometry
|
||||
/// binding for that node, because eligible descendants can still carry hits.
|
||||
fn linked_node_postorder(model: &ModelAsset, root: NodeId) -> Result<Vec<NodeId>, MshError> {
|
||||
let root_index = usize::try_from(root.0).map_err(|_| {
|
||||
MshError::InvalidGeometry(format!("linked query node {} is not addressable", root.0))
|
||||
})?;
|
||||
if root_index >= model.node_count {
|
||||
return Err(MshError::InvalidGeometry(format!(
|
||||
"linked query node {} is outside Node38 count {}",
|
||||
root.0, model.node_count
|
||||
)));
|
||||
}
|
||||
|
||||
let metadata = (0..model.node_count)
|
||||
.map(|index| {
|
||||
let node = NodeId(u32::try_from(index).map_err(|_| {
|
||||
MshError::InvalidGeometry(format!("Node38 index {index} is not addressable"))
|
||||
})?);
|
||||
node38_metadata(model, node).ok_or_else(|| {
|
||||
MshError::InvalidGeometry(format!(
|
||||
"linked query requires Node38 metadata for node {index}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for (index, entry) in metadata.iter().enumerate() {
|
||||
if entry.parent_or_link_raw != u16::MAX
|
||||
&& usize::from(entry.parent_or_link_raw) >= model.node_count
|
||||
{
|
||||
return Err(MshError::InvalidGeometry(format!(
|
||||
"Node38 parent {} for node {index} is outside node count {}",
|
||||
entry.parent_or_link_raw, model.node_count
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Each stack frame marks entry or postorder exit. This keeps traversal
|
||||
// bounded by the model table rather than by the native hierarchy depth.
|
||||
let mut stack = vec![(root_index, false)];
|
||||
let mut active = vec![false; model.node_count];
|
||||
let mut visited = vec![false; model.node_count];
|
||||
let mut ordered = Vec::new();
|
||||
while let Some((index, exiting)) = stack.pop() {
|
||||
if exiting {
|
||||
active[index] = false;
|
||||
visited[index] = true;
|
||||
ordered.push(NodeId(u32::try_from(index).map_err(|_| {
|
||||
MshError::InvalidGeometry(format!("Node38 index {index} is not addressable"))
|
||||
})?));
|
||||
continue;
|
||||
}
|
||||
if active[index] {
|
||||
return Err(MshError::InvalidGeometry(format!(
|
||||
"linked Node38 query contains a cycle at node {index}"
|
||||
)));
|
||||
}
|
||||
if visited[index] {
|
||||
continue;
|
||||
}
|
||||
|
||||
active[index] = true;
|
||||
stack.push((index, true));
|
||||
if metadata[index].header_flags & 0x0010 == 0 {
|
||||
let parent_word = u16::try_from(index).map_err(|_| {
|
||||
MshError::InvalidGeometry(format!(
|
||||
"Node38 index {index} cannot be represented by its parent field"
|
||||
))
|
||||
})?;
|
||||
for child in (0..model.node_count).rev() {
|
||||
if metadata[child].parent_or_link_raw != u16::MAX
|
||||
&& metadata[child].parent_or_link_raw == parent_word
|
||||
&& metadata[child].header_flags & 0x0004 == 0
|
||||
{
|
||||
stack.push((child, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ordered)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -222,11 +344,22 @@ mod tests {
|
||||
use super::{
|
||||
query_building_segment_world, BuildingMeshHit, BuildingMeshNode, MAX_INTERSECTION_VISITS,
|
||||
};
|
||||
use fparkan_msh::{Batch, ModelAsset, MshError, NodeId, Slot, SlotId, TriangleDescriptor};
|
||||
use fparkan_msh::{
|
||||
query_slot_segment, Batch, ModelAsset, MshError, NodeId, SegmentQueryMasks, Slot, SlotId,
|
||||
TriangleDescriptor,
|
||||
};
|
||||
|
||||
const IDENTITY: [f32; 16] = [
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
||||
];
|
||||
const CAMERA_MASKS: SegmentQueryMasks = SegmentQueryMasks {
|
||||
node_include: 0,
|
||||
node_exclude: 0,
|
||||
batch_include: 0,
|
||||
batch_exclude: 0x0200,
|
||||
triangle_include: 0,
|
||||
triangle_exclude: 0x0024,
|
||||
};
|
||||
|
||||
fn node(node: u32, world_from_node: [f32; 16]) -> BuildingMeshNode {
|
||||
node_with_slot(node, 0, world_from_node)
|
||||
@@ -245,6 +378,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn model_with_slots(planes_per_slot: &[&[(f32, u16)]]) -> ModelAsset {
|
||||
model_with_slots_and_nodes(planes_per_slot, 2)
|
||||
}
|
||||
|
||||
fn model_with_slots_and_nodes(
|
||||
planes_per_slot: &[&[(f32, u16)]],
|
||||
node_count: usize,
|
||||
) -> ModelAsset {
|
||||
let mut positions = Vec::new();
|
||||
let mut indices = Vec::new();
|
||||
let mut batches = Vec::new();
|
||||
@@ -253,18 +393,21 @@ mod tests {
|
||||
for planes in planes_per_slot {
|
||||
let descriptor_start = triangle_descriptors.len();
|
||||
let batch_start = batches.len();
|
||||
let min_y = planes.iter().map(|(y, _)| *y).fold(0.0, f32::min);
|
||||
let max_y = planes.iter().map(|(y, _)| *y).fold(0.0, f32::max);
|
||||
let center_y = (min_y + max_y) * 0.5;
|
||||
let half_height = (max_y - min_y) * 0.5;
|
||||
for (y, batch_flags) in *planes {
|
||||
let base_vertex = positions.len();
|
||||
let index_start = indices.len();
|
||||
positions.extend([[-2.0, *y, -2.0], [0.0, *y, 2.0], [2.0, *y, -2.0]]);
|
||||
positions.extend([[-20.0, *y, -20.0], [0.0, *y, 20.0], [20.0, *y, -20.0]]);
|
||||
indices.extend([0, 1, 2]);
|
||||
batches.push(Batch {
|
||||
batch_flags: *batch_flags,
|
||||
material_index_hi: 0,
|
||||
material_index: 0,
|
||||
lightmap_index: 0,
|
||||
local_batch_index: u16::try_from(batches.len() - batch_start)
|
||||
.expect("small synthetic batch index"),
|
||||
local_batch_index: u16::MAX,
|
||||
index_count: 3,
|
||||
index_start: u32::try_from(index_start).expect("small synthetic index offset"),
|
||||
vertex_count: 3,
|
||||
@@ -284,18 +427,18 @@ mod tests {
|
||||
batch_start: u16::try_from(batch_start).expect("small synthetic batch start"),
|
||||
batch_count: u16::try_from(batches.len() - batch_start)
|
||||
.expect("small synthetic batch count"),
|
||||
aabb_min: [-2.0, -10.0, -2.0],
|
||||
aabb_max: [2.0, 10.0, 2.0],
|
||||
sphere_center: [0.0; 3],
|
||||
sphere_radius: 0.0,
|
||||
aabb_min: [-20.0, min_y, -20.0],
|
||||
aabb_max: [20.0, max_y, 20.0],
|
||||
sphere_center: [0.0, center_y, 0.0],
|
||||
sphere_radius: (20.0_f32.powi(2) * 2.0 + half_height.powi(2)).sqrt(),
|
||||
opaque: [0; 5],
|
||||
});
|
||||
}
|
||||
|
||||
ModelAsset {
|
||||
node_stride: 38,
|
||||
node_count: 2,
|
||||
nodes_raw: vec![0; 76],
|
||||
node_count,
|
||||
nodes_raw: vec![0; node_count * 38],
|
||||
type16_present: false,
|
||||
bounding_sphere_center: [0.0; 3],
|
||||
bounding_sphere_radius: 0.0,
|
||||
@@ -312,6 +455,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_node_metadata(model: &mut ModelAsset, node: usize, flags: u16, parent: u16) {
|
||||
let offset = node * model.node_stride;
|
||||
model.nodes_raw[offset..offset + 2].copy_from_slice(&flags.to_le_bytes());
|
||||
model.nodes_raw[offset + 2..offset + 4].copy_from_slice(&parent.to_le_bytes());
|
||||
}
|
||||
|
||||
fn set_batch_link(model: &mut ModelAsset, batch: usize, node: u16) {
|
||||
model.batches[batch].local_batch_index = node;
|
||||
}
|
||||
|
||||
fn set_root_node_metadata(model: &mut ModelAsset) {
|
||||
for node in 0..model.node_count {
|
||||
set_node_metadata(model, node, 0, u16::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
fn translated(x: f32, y: f32, z: f32) -> [f32; 16] {
|
||||
let mut matrix = IDENTITY;
|
||||
matrix[3] = x;
|
||||
@@ -418,7 +577,9 @@ mod tests {
|
||||
fn retries_from_bit8_helper_to_the_farther_wall() {
|
||||
let helper_plane = [(2.0, 0x0008)];
|
||||
let wall_plane = [(0.0, 0)];
|
||||
let model = model_with_slots(&[&helper_plane, &wall_plane]);
|
||||
let mut model = model_with_slots(&[&helper_plane, &wall_plane]);
|
||||
set_root_node_metadata(&mut model);
|
||||
set_batch_link(&mut model, 0, 1);
|
||||
let result = query_building_segment_world(
|
||||
&model,
|
||||
&[
|
||||
@@ -439,10 +600,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn coplanar_helper_hit_is_requeried_instead_of_filtered_out() {
|
||||
let model = model_with_planes(&[(0.0, 0), (0.0, 0x0008)]);
|
||||
let mut model = model_with_slots(&[&[(0.0, 0)], &[(0.0, 0x0008)]]);
|
||||
set_root_node_metadata(&mut model);
|
||||
set_batch_link(&mut model, 1, 1);
|
||||
let result = query_building_segment_world(
|
||||
&model,
|
||||
&[node(0, IDENTITY)],
|
||||
&[
|
||||
node_with_slot(1, 0, IDENTITY),
|
||||
node_with_slot(0, 1, IDENTITY),
|
||||
],
|
||||
[[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
|
||||
)
|
||||
.expect("valid world query");
|
||||
@@ -450,6 +616,229 @@ mod tests {
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_roundtrip_helper_cannot_be_reselected_after_portal_link() {
|
||||
const START: [f32; 3] = [301.39682, 806.24396, 2.868942];
|
||||
const END: [f32; 3] = [304.3922, 806.4102, 2.868942];
|
||||
let world_from_node = [
|
||||
f32::from_bits(0xbd62_f745),
|
||||
f32::from_bits(0xbf7f_9b4f),
|
||||
0.0,
|
||||
f32::from_bits(0x4379_43ca),
|
||||
f32::from_bits(0x3f7f_9b4f),
|
||||
f32::from_bits(0xbd62_f745),
|
||||
0.0,
|
||||
f32::from_bits(0x4448_b667),
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
f32::from_bits(0x4110_2494),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
];
|
||||
let mut model = model_with_slots_and_nodes(&[&[(-54.239994, 0x0008)]], 4);
|
||||
set_batch_link(&mut model, 0, 2);
|
||||
set_node_metadata(&mut model, 0, 0, u16::MAX);
|
||||
set_node_metadata(&mut model, 1, 0x0104, 0);
|
||||
set_node_metadata(&mut model, 2, 0x0004, 1);
|
||||
set_node_metadata(&mut model, 3, 0x00d1, 2);
|
||||
let source = node(1, world_from_node);
|
||||
|
||||
// Reproduce the old all-node retry: the world-space first hit maps
|
||||
// back to a local start just above this same helper plane.
|
||||
let prepared = super::prepare_node(source).expect("invert actual AutoDemo matrix");
|
||||
let local_start =
|
||||
super::transform_point(prepared.node_from_world, START).expect("finite local start");
|
||||
let local_end =
|
||||
super::transform_point(prepared.node_from_world, END).expect("finite local endpoint");
|
||||
let first_hit = query_slot_segment(
|
||||
&model,
|
||||
NodeId(1),
|
||||
SlotId(0),
|
||||
[local_start, local_end],
|
||||
CAMERA_MASKS,
|
||||
)
|
||||
.expect("valid first helper query")
|
||||
.expect("first helper crossing");
|
||||
let world_hit =
|
||||
super::transform_point(world_from_node, first_hit.position).expect("finite world hit");
|
||||
let repeated_local_start = super::transform_point(prepared.node_from_world, world_hit)
|
||||
.expect("finite round-tripped start");
|
||||
assert!(query_slot_segment(
|
||||
&model,
|
||||
NodeId(1),
|
||||
SlotId(0),
|
||||
[repeated_local_start, local_end],
|
||||
CAMERA_MASKS,
|
||||
)
|
||||
.expect("valid repeated helper query")
|
||||
.is_some());
|
||||
|
||||
let result = query_building_segment_world(&model, &[source], [START, END])
|
||||
.expect("valid portal-linked world query");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_subtree_finds_drawable_child_through_nondrawable_parent() {
|
||||
let mut model = model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)]], 4);
|
||||
set_root_node_metadata(&mut model);
|
||||
set_batch_link(&mut model, 0, 2);
|
||||
set_node_metadata(&mut model, 1, 0x0104, 0);
|
||||
set_node_metadata(&mut model, 2, 0x0004, 1);
|
||||
set_node_metadata(&mut model, 3, 0x00d1, 2);
|
||||
|
||||
let result = query_building_segment_world(
|
||||
&model,
|
||||
&[
|
||||
node_with_slot(1, 0, IDENTITY),
|
||||
node_with_slot(3, 1, IDENTITY),
|
||||
],
|
||||
[[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
|
||||
)
|
||||
.expect("valid linked-child query");
|
||||
|
||||
let hit = hit(result);
|
||||
assert_eq!(hit.node, NodeId(3));
|
||||
assert_eq!(hit.slot, SlotId(1));
|
||||
assert_eq!(hit.batch_index, 1);
|
||||
assert_point(hit.position, [0.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_query_is_child_before_self_and_self_bit4_does_not_skip_geometry() {
|
||||
let mut model =
|
||||
model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0)]], 4);
|
||||
set_root_node_metadata(&mut model);
|
||||
set_batch_link(&mut model, 0, 2);
|
||||
set_node_metadata(&mut model, 1, 0x0104, 0);
|
||||
set_node_metadata(&mut model, 2, 0x0004, 1);
|
||||
set_node_metadata(&mut model, 3, 0x00d1, 2);
|
||||
|
||||
let result = query_building_segment_world(
|
||||
&model,
|
||||
&[
|
||||
node_with_slot(1, 0, IDENTITY),
|
||||
node_with_slot(2, 1, IDENTITY),
|
||||
node_with_slot(3, 2, IDENTITY),
|
||||
],
|
||||
[[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
|
||||
)
|
||||
.expect("valid child-first linked query");
|
||||
|
||||
let hit = hit(result);
|
||||
// The equal-distance later candidate replaces the child result.
|
||||
assert_eq!(hit.node, NodeId(2));
|
||||
assert_eq!(hit.slot, SlotId(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_query_traverses_nondrawable_nodes_and_skips_child_bit4_branches() {
|
||||
let mut model = model_with_slots_and_nodes(
|
||||
&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0)], &[(0.0, 0)]],
|
||||
7,
|
||||
);
|
||||
set_root_node_metadata(&mut model);
|
||||
set_batch_link(&mut model, 0, 2);
|
||||
set_node_metadata(&mut model, 1, 0x0104, 0);
|
||||
set_node_metadata(&mut model, 2, 0x0004, 1);
|
||||
set_node_metadata(&mut model, 3, 0x0000, 2);
|
||||
set_node_metadata(&mut model, 4, 0x0010, 3);
|
||||
set_node_metadata(&mut model, 5, 0x0004, 3);
|
||||
set_node_metadata(&mut model, 6, 0x0004, 2);
|
||||
|
||||
let result = query_building_segment_world(
|
||||
&model,
|
||||
&[
|
||||
node_with_slot(1, 0, IDENTITY),
|
||||
node_with_slot(4, 1, IDENTITY),
|
||||
node_with_slot(5, 2, IDENTITY),
|
||||
node_with_slot(6, 3, IDENTITY),
|
||||
],
|
||||
[[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
|
||||
)
|
||||
.expect("valid linked branch query");
|
||||
|
||||
let hit = hit(result);
|
||||
assert_eq!(hit.node, NodeId(4));
|
||||
assert_eq!(hit.slot, SlotId(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_query_keeps_native_batch_and_triangle_exclude_masks() {
|
||||
let mut model =
|
||||
model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0x0200)]], 5);
|
||||
set_root_node_metadata(&mut model);
|
||||
set_batch_link(&mut model, 0, 2);
|
||||
set_node_metadata(&mut model, 1, 0x0104, 0);
|
||||
set_node_metadata(&mut model, 2, 0x0004, 1);
|
||||
set_node_metadata(&mut model, 3, 0x00d1, 2);
|
||||
set_node_metadata(&mut model, 4, 0x00d1, 2);
|
||||
model
|
||||
.triangle_descriptors
|
||||
.as_mut()
|
||||
.expect("synthetic type-7 table")[1]
|
||||
.tri_flags = 0x0024;
|
||||
|
||||
let result = query_building_segment_world(
|
||||
&model,
|
||||
&[
|
||||
node_with_slot(1, 0, IDENTITY),
|
||||
node_with_slot(3, 1, IDENTITY),
|
||||
node_with_slot(4, 2, IDENTITY),
|
||||
],
|
||||
[[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
|
||||
)
|
||||
.expect("valid query with native exclusion masks");
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_order_stops_descent_at_header_bit10_but_keeps_self() {
|
||||
let mut model = model_with_slots_and_nodes(&[&[]], 5);
|
||||
set_root_node_metadata(&mut model);
|
||||
set_node_metadata(&mut model, 1, 0, 0);
|
||||
set_node_metadata(&mut model, 2, 0x0014, 1);
|
||||
set_node_metadata(&mut model, 3, 0, 2);
|
||||
set_node_metadata(&mut model, 4, 0, 3);
|
||||
|
||||
let order = super::linked_node_postorder(&model, NodeId(2)).expect("valid linked tree");
|
||||
|
||||
assert_eq!(order, [NodeId(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_portal_link_and_linked_cycle_are_explicit_errors() {
|
||||
let mut invalid_link = model_with_planes(&[(2.0, 0x0008)]);
|
||||
set_root_node_metadata(&mut invalid_link);
|
||||
set_batch_link(&mut invalid_link, 0, 2);
|
||||
assert!(matches!(
|
||||
query_building_segment_world(
|
||||
&invalid_link,
|
||||
&[node(0, IDENTITY)],
|
||||
[[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
|
||||
),
|
||||
Err(MshError::InvalidGeometry(message)) if message.contains("linked")
|
||||
));
|
||||
|
||||
let mut cycle = model_with_slots_and_nodes(&[&[(2.0, 0x0008)]], 3);
|
||||
set_batch_link(&mut cycle, 0, 1);
|
||||
set_node_metadata(&mut cycle, 0, 0, u16::MAX);
|
||||
set_node_metadata(&mut cycle, 1, 0, 2);
|
||||
set_node_metadata(&mut cycle, 2, 0, 1);
|
||||
assert!(matches!(
|
||||
query_building_segment_world(
|
||||
&cycle,
|
||||
&[node(0, IDENTITY)],
|
||||
[[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
|
||||
),
|
||||
Err(MshError::InvalidGeometry(message)) if message.contains("cycle")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_non_affine_singular_and_nonfinite_transforms_are_explicit_errors() {
|
||||
let mut non_affine = IDENTITY;
|
||||
@@ -493,8 +882,13 @@ mod tests {
|
||||
.map(|index| ((helper_count - index) as f32, 0x0008))
|
||||
.collect::<Vec<_>>();
|
||||
planes.push((0.0, 0));
|
||||
let mut model = model_with_planes(&planes);
|
||||
set_root_node_metadata(&mut model);
|
||||
for batch in 0..helper_count {
|
||||
set_batch_link(&mut model, batch, 0);
|
||||
}
|
||||
let result = query_building_segment_world(
|
||||
&model_with_planes(&planes),
|
||||
&model,
|
||||
&[node(0, IDENTITY)],
|
||||
[[0.0, (helper_count + 1) as f32, 0.0], [0.0, -1.0, 0.0]],
|
||||
)
|
||||
@@ -512,8 +906,13 @@ mod tests {
|
||||
.map(|index| ((helper_count - index) as f32, 0x0008))
|
||||
.collect::<Vec<_>>();
|
||||
planes.push((0.0, 0));
|
||||
let mut model = model_with_planes(&planes);
|
||||
set_root_node_metadata(&mut model);
|
||||
for batch in 0..helper_count {
|
||||
set_batch_link(&mut model, batch, 0);
|
||||
}
|
||||
let result = query_building_segment_world(
|
||||
&model_with_planes(&planes),
|
||||
&model,
|
||||
&[node(0, IDENTITY)],
|
||||
[[0.0, (helper_count + 1) as f32, 0.0], [0.0, -1.0, 0.0]],
|
||||
);
|
||||
@@ -536,6 +935,117 @@ mod tests {
|
||||
result
|
||||
}
|
||||
|
||||
fn pose_matrix(pose: fparkan_animation::Pose) -> [f32; 16] {
|
||||
let x = rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation);
|
||||
let y = rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation);
|
||||
let z = rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation);
|
||||
[
|
||||
x[0],
|
||||
y[0],
|
||||
z[0],
|
||||
pose.translation[0],
|
||||
x[1],
|
||||
y[1],
|
||||
z[1],
|
||||
pose.translation[1],
|
||||
x[2],
|
||||
y[2],
|
||||
z[2],
|
||||
pose.translation[2],
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
]
|
||||
}
|
||||
|
||||
fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
|
||||
let [x, y, z, w] = rotation;
|
||||
let tx = 2.0 * (y * value[2] - z * value[1]);
|
||||
let ty = 2.0 * (z * value[0] - x * value[2]);
|
||||
let tz = 2.0 * (x * value[1] - y * value[0]);
|
||||
[
|
||||
value[0] + w * tx + (y * tz - z * ty),
|
||||
value[1] + w * ty + (z * tx - x * tz),
|
||||
value[2] + w * tz + (x * ty - y * tx),
|
||||
]
|
||||
}
|
||||
|
||||
fn scale_matrix(scale: [f32; 3]) -> [f32; 16] {
|
||||
[
|
||||
scale[0], 0.0, 0.0, 0.0, 0.0, scale[1], 0.0, 0.0, 0.0, 0.0, scale[2], 0.0, 0.0, 0.0,
|
||||
0.0, 1.0,
|
||||
]
|
||||
}
|
||||
|
||||
fn node_times_from_bindings(
|
||||
node_count: usize,
|
||||
bindings: &[fparkan_assets::PreparedControlNodeBinding],
|
||||
) -> Vec<Option<fparkan_animation::AnimationTime>> {
|
||||
use fparkan_animation::AnimationTime;
|
||||
|
||||
let mut times = vec![Some(AnimationTime(0.0)); node_count];
|
||||
for binding in bindings {
|
||||
if binding.flags & 0x04 != 0 {
|
||||
continue;
|
||||
}
|
||||
assert!(binding.node_index < node_count);
|
||||
let mut blend = if binding.flags & 0x01 != 0 {
|
||||
let mut value = binding.initial_blend;
|
||||
if value > 1.0 {
|
||||
value -= 1.0;
|
||||
}
|
||||
if value < 0.0 {
|
||||
value += 1.0;
|
||||
}
|
||||
value
|
||||
} else {
|
||||
binding.initial_blend.clamp(0.0, 1.0)
|
||||
};
|
||||
if binding.flags & 0x02 != 0 {
|
||||
blend = 1.0 - blend;
|
||||
}
|
||||
times[binding.node_index] = Some(AnimationTime(
|
||||
(1.0 - blend) * binding.frame_a + blend * binding.frame_b,
|
||||
));
|
||||
}
|
||||
times
|
||||
}
|
||||
|
||||
fn nodes_for_hierarchy(
|
||||
model: &ModelAsset,
|
||||
poses: &[fparkan_animation::Pose],
|
||||
root_from_body: [f32; 16],
|
||||
owner_scale: [f32; 3],
|
||||
) -> Vec<BuildingMeshNode> {
|
||||
use fparkan_msh::{selected_slot, Group, Lod};
|
||||
|
||||
let scale = scale_matrix(owner_scale);
|
||||
(0..model.node_count)
|
||||
.filter_map(|index| {
|
||||
let node = NodeId(u32::try_from(index).expect("model node id fits u32"));
|
||||
let slot = selected_slot(model, node, Lod(0), Group(0))?;
|
||||
let slot_data = &model.slots[usize::try_from(slot.0).expect("slot id fits usize")];
|
||||
let batch_start = usize::from(slot_data.batch_start);
|
||||
let batch_end = batch_start + usize::from(slot_data.batch_count);
|
||||
if !model.batches[batch_start..batch_end]
|
||||
.iter()
|
||||
.any(|batch| batch.index_count != 0)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let node_pose = pose_matrix(*poses.get(index).expect("Node38 pose available"));
|
||||
let body_from_node = compose_row_major(scale, node_pose);
|
||||
let world_from_node = compose_row_major(root_from_body, body_from_node);
|
||||
Some(BuildingMeshNode {
|
||||
node,
|
||||
slot,
|
||||
world_from_node,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
fn factory_plant_world_query_preserves_native_fixture_hit_under_affine_transforms() {
|
||||
@@ -639,4 +1149,107 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires licensed AutoDemo corpus"]
|
||||
fn autodemo_portal_uses_node3_authored_closed_and_open_poses() {
|
||||
use crate::{
|
||||
create, load_mission_static_preview_roots, loaded_mission_assets,
|
||||
loaded_mission_object_drafts, EngineServices, MissionMode, MissionRequest,
|
||||
};
|
||||
use fparkan_animation::AnimationTime;
|
||||
use fparkan_msh::{node38_fallback_hierarchy, node38_sampled_hierarchy_at_times};
|
||||
use fparkan_render::LegacyIron3dEulerTransform;
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
use std::{num::NonZeroUsize, path::PathBuf, sync::Arc};
|
||||
|
||||
const OBJECT_INDEX: usize = 4;
|
||||
const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]];
|
||||
|
||||
let root = PathBuf::from(
|
||||
std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
|
||||
.expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed corpus tests"),
|
||||
);
|
||||
let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(&root))))
|
||||
.expect("create SingleMission fixture engine");
|
||||
load_mission_static_preview_roots(
|
||||
&mut engine,
|
||||
MissionRequest {
|
||||
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
||||
mode: MissionMode::SingleMission,
|
||||
},
|
||||
NonZeroUsize::MAX,
|
||||
)
|
||||
.expect("load SingleMission preview roots");
|
||||
let assets = loaded_mission_assets(&engine).expect("loaded mission assets");
|
||||
let drafts = loaded_mission_object_drafts(&engine).expect("loaded object drafts");
|
||||
let draft = &drafts[OBJECT_INDEX];
|
||||
assert!(draft
|
||||
.resource_name_raw
|
||||
.eq_ignore_ascii_case(b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat"));
|
||||
let visual_id = *assets
|
||||
.visuals_for_object(OBJECT_INDEX)
|
||||
.first()
|
||||
.expect("PLANT root visual");
|
||||
let visual = assets.visual_by_id(visual_id).expect("PLANT visual");
|
||||
let model_id = visual.model_id.expect("PLANT MSH model");
|
||||
let model = &assets
|
||||
.model_by_id(model_id)
|
||||
.expect("prepared PLANT model")
|
||||
.validated;
|
||||
assert_eq!(model.node_count, 29);
|
||||
|
||||
let controls = assets
|
||||
.object_component_controls
|
||||
.get(OBJECT_INDEX)
|
||||
.and_then(|components| components.first())
|
||||
.and_then(Option::as_ref)
|
||||
.expect("PLANT component CTLD bindings");
|
||||
let node3_binding = controls
|
||||
.bindings
|
||||
.iter()
|
||||
.find(|binding| binding.node_index == 3)
|
||||
.expect("Node3 CTLD binding");
|
||||
assert_eq!(node3_binding.frame_a, 0.0);
|
||||
assert_eq!(node3_binding.frame_b, 1.0);
|
||||
assert_eq!(node3_binding.initial_blend, 0.0);
|
||||
|
||||
let mut node_times = node_times_from_bindings(model.node_count, &controls.bindings);
|
||||
let closed_hierarchy = node38_sampled_hierarchy_at_times(model, &node_times)
|
||||
.or_else(|| node38_fallback_hierarchy(model))
|
||||
.expect("initial Node38 hierarchy");
|
||||
assert_eq!(node_times[3], Some(AnimationTime(0.0)));
|
||||
|
||||
let root_transform = LegacyIron3dEulerTransform {
|
||||
translation: draft.position,
|
||||
orientation_radians: draft.orientation_raw,
|
||||
};
|
||||
let root_from_body = root_transform
|
||||
.try_row_major()
|
||||
.expect("finite PLANT root matrix");
|
||||
let world_door = BODY_DOOR.map(|point| {
|
||||
root_transform
|
||||
.try_transform_scaled_point(point, draft.scale)
|
||||
.expect("finite BODY_DOOR world point")
|
||||
});
|
||||
let closed_nodes =
|
||||
nodes_for_hierarchy(model, &closed_hierarchy.poses, root_from_body, draft.scale);
|
||||
let closed = query_building_segment_world(model, &closed_nodes, world_door)
|
||||
.expect("closed-door building query")
|
||||
.expect("authored time-0 doorway blocker");
|
||||
assert_eq!(closed.node, NodeId(3));
|
||||
assert_eq!(closed.slot, SlotId(25));
|
||||
assert_eq!(closed.batch_index, 100);
|
||||
assert_eq!(closed.triangle_descriptor_index, 855);
|
||||
assert_eq!(model.batches[closed.batch_index].batch_flags, 0x2000);
|
||||
|
||||
node_times[3] = Some(AnimationTime(node3_binding.frame_b));
|
||||
let open_hierarchy = node38_sampled_hierarchy_at_times(model, &node_times)
|
||||
.expect("authored Node3 time-1 hierarchy");
|
||||
let open_nodes =
|
||||
nodes_for_hierarchy(model, &open_hierarchy.poses, root_from_body, draft.scale);
|
||||
assert!(query_building_segment_world(model, &open_nodes, world_door)
|
||||
.expect("time-1 building query")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -16,15 +16,21 @@ state transitions и окончательный порядок части transp
|
||||
|
||||
Локальный MSH-запрос `query_slot_segment` сверён с оригиналом на фабричной
|
||||
стене и дверном проёме. Runtime-адаптер `query_building_segment_world` теперь
|
||||
переводит отрезок через переданные преобразования выбранных MSH-узлов, выбирает
|
||||
ближайшее мировое попадание и повторяет запрос для helper batch bit `0x0008`.
|
||||
Его синтетические тесты покрывают поворот, масштаб, отражение, helper-повтор и
|
||||
ограничение в 200 пересечений; они не доказывают паритет с полным мировым
|
||||
запросом оригинала.
|
||||
повторяет helper batch bit `0x0008` через связанный узел, указанный raw-словом
|
||||
`Batch20 +0x06`. При повторе он обходит Node38-поддерево: сначала допустимых
|
||||
потомков, затем сам узел. Флаги ветвления сверены с оригиналом. Синтетические
|
||||
тесты покрывают связи, отсутствие геометрии у промежуточного узла, маски,
|
||||
равные попадания и предел в 200 пересечений.
|
||||
|
||||
Открыты выбор нативной категории и состава узлов/слотов, получение актуальных
|
||||
поз и component bindings из runtime-объектов, подключение адаптера к свободной
|
||||
камере и сравнение мирового результата с оригиналом
|
||||
Игнорируемый тест на AutoDemo загружает настоящий объект PLANT 4 и подтверждает,
|
||||
что сегмент пересекает геометрию Node3 в исходной закрытой позе, но не
|
||||
пересекает её в открытой позе Node3 на frame `1`. В загруженных
|
||||
Control-привязках корня PLANT нет tag-21 записи для этого перехода. Тест задаёт
|
||||
frame `1` напрямую и не исполняет команду открытия из миссии.
|
||||
|
||||
Открыты выбор первого корня и состава узлов/слотов нативного мирового запроса,
|
||||
полный обход текущих runtime-поз и component bindings, подключение адаптера к
|
||||
свободной камере и сравнение итогового результата мирового запроса с оригиналом
|
||||
([MSH](../reference/msh.md)).
|
||||
|
||||
Для MSH без потока цветов type 16 подтверждён только один постоянный alpha
|
||||
|
||||
+37
-19
@@ -153,7 +153,10 @@ struct Batch20 {
|
||||
```
|
||||
|
||||
Byte `material_index` выбирает строку WEAR; high word at `+0x02` сохраняется
|
||||
отдельно для native overrides. Loader проверяет, что
|
||||
отдельно для native overrides. Слово `local_batch_index` остаётся raw-полем
|
||||
`+0x06`: в пути CBuilding helper retry оно вместе с базой модели задаёт
|
||||
связанный корень Node38, а у отдельной `ModelAsset` база равна нулю. Вне этого
|
||||
пути слово сохраняется без вывода о его значении. Loader проверяет, что
|
||||
`index_start + index_count` остаётся внутри type 6, а каждый использованный
|
||||
индекс через `base_vertex` попадает в position stream. `vertex_count` и
|
||||
`local_batch_index` доступны renderer для исходного draw contract.
|
||||
@@ -200,11 +203,10 @@ Rust использует обычное деление `f32`, поэтому п
|
||||
### Мировой отрезок по выбранным узлам
|
||||
|
||||
Локальный запрос выше проверяет один узел и slot модели. Runtime-функция
|
||||
`query_building_segment_world` принимает мировой отрезок и список выбранных
|
||||
узлов со slot-ами и их преобразованиями `world_from_node`. Для каждого узла она
|
||||
переводит оба конца отрезка обратной матрицей, вызывает `query_slot_segment`, а
|
||||
найденную точку переносит назад в мир. Затем она выбирает ближайшее попадание
|
||||
среди переданных узлов.
|
||||
`query_building_segment_world` принимает мировой отрезок и исходный список
|
||||
выбранных узлов со slot-ами и их преобразованиями `world_from_node`. Первый
|
||||
проход сохраняет порядок этого списка, переводит концы отрезка обратной
|
||||
матрицей и выбирает ближайшее попадание в мире.
|
||||
|
||||
```text
|
||||
segment_node = inverse(world_from_node) * segment_world
|
||||
@@ -213,20 +215,36 @@ distance² = dot(hit_world - original_start, hit_world - original_start)
|
||||
```
|
||||
|
||||
`original_start` остаётся началом первоначального мирового отрезка, даже когда
|
||||
запрос перезапускается. Нативный CBuilding query сохраняет raw
|
||||
`Batch20.batch_flags & 0x0008`: если ближайшее попадание помечено этим bit,
|
||||
следующий проход начинается в его мировой точке и идёт к исходному концу.
|
||||
Первичный запрос исключает из batch только `0x0200`, а тип-7 треугольники —
|
||||
`0x0024`. Счётчик ограничивает число всех пересечений: при посещении 200
|
||||
запрос завершается ошибкой до проверки bit `0x0008`.
|
||||
запрос перезапускается. Если ближайший batch имеет bit `0x0008`, его ключ
|
||||
`local_batch_index` выбирает новый корень Node38, а отрезок начинается в
|
||||
мировой точке helper-попадания и идёт к исходному концу. Runtime обходит
|
||||
метаданные этого поддерева даже у узлов без переданной геометрии; сами slot-
|
||||
запросы выполняются только для узлов, присутствующих во входном списке. Обход
|
||||
проверяет сначала допустимых потомков, затем сам узел. Header bit `0x0010`
|
||||
прекращает обход его потомков; bit `0x0004` у дочернего узла исключает всю эту ветвь, но bit
|
||||
`0x0004` у текущего корня не мешает запросу его собственной геометрии. Слово
|
||||
родителя `0xFFFF` не связывает узел с дочерним корнем, а helper-ключ `0xFFFF`
|
||||
считается ошибкой данных.
|
||||
|
||||
Эта схема восстановлена по GOG `Terrain.dll` SHA-256
|
||||
`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`:
|
||||
мировой обход проходит через `FUN_10016a20` (RVA `0x16A20`), CBuilding выполняет
|
||||
запрос и helper-перезапуски в `FUN_10035430` (RVA `0x35430`), а guard находится
|
||||
в участке `0x358FC..0x35926`. Runtime-модуль проверен на синтетических
|
||||
переносах, поворотах, масштабах и отражениях; подключение к текущим позам
|
||||
объектов и камере приложения пока не выполнено.
|
||||
В каждом проходе batch `0x0200` и type-7 triangle flags `0x0024` исключаются.
|
||||
Ближайшее попадание и разрешение равных расстояний сохраняют правила локального
|
||||
запроса; расстояние измеряется от первоначального начала. Счётчик ограничивает
|
||||
все пересечения: при посещении 200 запрос завершается ошибкой до проверки bit
|
||||
`0x0008`.
|
||||
|
||||
Эта схема сверена с GOG `Terrain.dll` SHA-256
|
||||
`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` (RVA
|
||||
`0x35430`, `0x35961`) и `AniMesh.dll` SHA-256
|
||||
`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e` (RVA
|
||||
`0x134D0`, `0x10A50`). Игнорируемый тест загружает настоящий объект PLANT 4
|
||||
миссии AutoDemo и проверяет, что сегмент пересекает геометрию Node3 в исходной
|
||||
закрытой позе, но проходит без попадания при явно заданной открытой позе frame
|
||||
`1`. Это проверка запроса для двух поз, а не открытие двери игровой командой.
|
||||
В загруженных Control-привязках корня PLANT нет tag-21 записи для перехода
|
||||
Node3; тест задаёт frame `1` напрямую и не исполняет команду открытия из
|
||||
миссии.
|
||||
Нативный выбор первого корня и полный мировой обход ещё не сопоставлены с Rust;
|
||||
app пока не передаёт этой функции живые runtime-позы и bindings.
|
||||
|
||||
## Сторона грани в цветовом проходе
|
||||
|
||||
|
||||
Reference in new issue
Block a user