diff options
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.vert | 9 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.vert.spv | bin | 18580 -> 19136 bytes | |||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/smoke.rs | 15 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs | 38 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/tests.rs | 2 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 574 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/ambient_op3.rs | 984 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/fx_visual.rs | 1428 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 17 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/op7.rs | 1443 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/smoke.rs | 1360 | ||||
| -rw-r--r-- | crates/fparkan-material/src/lib.rs | 258 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/contour.rs | 809 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/foundation.rs | 1806 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 362 | ||||
| -rw-r--r-- | docs/reference/environment-effects.md | 106 | ||||
| -rw-r--r-- | docs/reference/materials.md | 7 | ||||
| -rw-r--r-- | docs/reference/msh.md | 47 | ||||
| -rw-r--r-- | docs/tomes/05-render.md | 38 |
19 files changed, 9230 insertions, 73 deletions
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert index 201c31a..83a1751 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.vert +++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert @@ -232,9 +232,10 @@ void main() { out_uv = in_uv; out_detail_uv = in_detail_uv; out_overlay_alpha = in_overlay_alpha; - out_fog_factor = native_fog_factor(in_position); int mode = int(round(material.material_mode)); int combiner_mode = mode & 3; + bool screen_tl = (mode & 8) != 0; + out_fog_factor = screen_tl ? 0.0 : native_fog_factor(in_position); if (combiner_mode == 0) { LightingTerms base_terms = native_material_lighting( in_position, @@ -262,12 +263,14 @@ void main() { out_overlay_diffuse = vec3(1.0); out_overlay_specular = vec3(0.0); } - vec4 clip_position = frame.clip_from_world * vec4(in_position, 1.0); + vec4 clip_position = screen_tl + ? vec4(in_position, 1.0) + : frame.clip_from_world * vec4(in_position, 1.0); // Sky far-depth is a Vulkan projection choice for layers authored around // the camera. Keep world positions, normals, and UVs intact while placing // the resulting primitive at the far depth; this does not assert native // D3D depth-bit equivalence. - if ((mode & 4) != 0) { + if ((mode & 4) != 0 && !screen_tl) { clip_position.z = clip_position.w; } gl_Position = clip_position; diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv Binary files differindex 9574e1c..535bde0 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv +++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index c9de5ba..ac359c2 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -3,7 +3,7 @@ use ash::vk; use fparkan_render::{LegacyDepthMode, LegacyPipelineState}; -use super::smoke_types::resolve_draw_texture_indices; +use super::smoke_types::{resolve_draw_texture_indices, VulkanStaticMaterial}; use super::update_material_texture_descriptor; use super::{ create_command_pool, create_frame_sync, create_index_buffer, create_static_mesh_index_buffer, @@ -361,18 +361,7 @@ impl VulkanSmokeRenderer { create_info .materials .iter() - .map(|material| { - let combiner_mode = if material.lightmap_mode { - 3.0 - } else if material.sky_nebula_stars { - 2.0 - } else if material.unlit { - 1.0 - } else { - 0.0 - }; - combiner_mode + if material.sky_far_depth { 4.0 } else { 0.0 } - }) + .map(VulkanStaticMaterial::encoded_material_mode) .collect() }; let material_specular = if create_info.materials.is_empty() { diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index f628e23..7a8837d 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -505,6 +505,33 @@ pub struct VulkanStaticMaterial { /// renderer projection choice; it is not a claim about the original D3D /// depth-bit encoding. pub sky_far_depth: bool, + /// Push material mode bit 8 and treat positions as Vulkan clip-space + /// `XYZRHW` vertices. Their x/y coordinates are converted from viewport + /// pixels by the caller and z retains the native `0.999` depth. + pub screen_tl: bool, +} + +impl VulkanStaticMaterial { + pub(crate) fn encoded_material_mode(&self) -> f32 { + let combiner_mode = if self.screen_tl { + 1.0 + } else if self.lightmap_mode { + 3.0 + } else if self.sky_nebula_stars { + 2.0 + } else if self.unlit { + 1.0 + } else { + 0.0 + }; + combiner_mode + + if self.sky_far_depth && !self.screen_tl { + 4.0 + } else { + 0.0 + } + + if self.screen_tl { 8.0 } else { 0.0 } + } } /// One authored RGBA8 mip level accepted by the Vulkan texture upload path. @@ -1028,6 +1055,7 @@ mod static_mesh_tests { sky_nebula_stars: false, lightmap_mode: false, sky_far_depth: false, + screen_tl: false, }, VulkanStaticMaterial { material_index: 7, @@ -1054,9 +1082,19 @@ mod static_mesh_tests { sky_nebula_stars: false, lightmap_mode: false, sky_far_depth: false, + screen_tl: false, }, ]; + let mut screen_tl = materials[0].clone(); + screen_tl.screen_tl = true; + screen_tl.sky_far_depth = true; + assert_eq!(screen_tl.encoded_material_mode(), 9.0); + let mut far_sky = materials[0].clone(); + far_sky.unlit = true; + far_sky.sky_far_depth = true; + assert_eq!(far_sky.encoded_material_mode(), 5.0); + assert_eq!( resolve_draw_texture_indices(&ranges, &materials), Ok(vec![1, 0]) diff --git a/adapters/fparkan-render-vulkan/src/ffi/tests.rs b/adapters/fparkan-render-vulkan/src/ffi/tests.rs index b8a3d71..4ed61f7 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/tests.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs @@ -533,7 +533,7 @@ fn native_specular_reference_uses_squared_power_and_total_overflow_curve() { fn shader_computes_radial_fog_per_vertex_and_interpolates_it() { let vertex = include_str!("../../shaders/triangle.vert"); let fragment = include_str!("../../shaders/triangle.frag"); - assert!(vertex.contains("out_fog_factor = native_fog_factor(in_position);")); + assert!(vertex.contains("out_fog_factor = screen_tl ? 0.0 : native_fog_factor(in_position);")); assert!(fragment.contains("layout(location = 4) in float in_fog_factor;")); assert!(!fragment.contains("distance(in_world_position")); assert!(!fragment.contains("float fog_factor()")); diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 438e345..3b3969e 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -32,8 +32,8 @@ use fparkan_path::ResourceName; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO}; use fparkan_render::{ - LegacyBlendMode, LegacyD3d7Projection, LegacyDepthMode, LegacyIron3dEulerTransform, - LegacyPipelineState, RawCameraTransform, + LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthMode, + LegacyIron3dEulerTransform, LegacyPipelineState, RawCameraTransform, }; use fparkan_render_vulkan::{ node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, @@ -924,6 +924,113 @@ fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) - diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0 } +fn native_msh_handedness(transform: LegacyIron3dEulerTransform, scale: [f32; 3]) -> i8 { + let Some(origin) = transform.try_transform_scaled_point([0.0; 3], scale) else { + return 0; + }; + let Some(x_axis) = transform + .try_transform_scaled_point([1.0, 0.0, 0.0], scale) + .map(|point| sub3(point, origin)) + else { + return 0; + }; + let Some(y_axis) = transform + .try_transform_scaled_point([0.0, 1.0, 0.0], scale) + .map(|point| sub3(point, origin)) + else { + return 0; + }; + let Some(z_axis) = transform + .try_transform_scaled_point([0.0, 0.0, 1.0], scale) + .map(|point| sub3(point, origin)) + else { + return 0; + }; + // Node and mount poses in the preview are unit-quaternion rotations, so + // they have determinant +1 and preserve this exact object-transform sign. + let determinant_sign = dot3(x_axis, cross3(y_axis, z_axis)); + if !determinant_sign.is_finite() || determinant_sign == 0.0 { + 0 + } else if determinant_sign < 0.0 { + -1 + } else { + 1 + } +} + +fn native_msh_cull_mode(batch_flags: u16, handedness: i8) -> LegacyCullMode { + if batch_flags & 0x2 != 0 || batch_flags & 0x4000 != 0 || handedness == 0 { + LegacyCullMode::Disabled + } else if handedness > 0 { + // The Y-negated projection maps native d > 0 faces to Vulkan's back + // under the renderer's CLOCKWISE front-face convention. + LegacyCullMode::FrontFace + } else { + LegacyCullMode::BackFace + } +} + +fn native_msh_first_triangle_visible( + mesh: &VulkanStaticMesh, + range: &VulkanStaticDrawRange, + eye_world: [f32; 3], + handedness: i8, +) -> bool { + if range.batch_flags & 0x2 != 0 || range.batch_flags & 0x4000 == 0 { + return true; + } + if handedness == 0 { + return false; + } + let Some(indices) = usize::try_from(range.first_index) + .ok() + .and_then(|first| mesh.indices.get(first..first.checked_add(3)?)) + else { + return false; + }; + let Some(v0) = usize::try_from(indices[0]) + .ok() + .and_then(|index| mesh.vertices.get(index)) + else { + return false; + }; + let Some(v1) = usize::try_from(indices[1]) + .ok() + .and_then(|index| mesh.vertices.get(index)) + else { + return false; + }; + let Some(v2) = usize::try_from(indices[2]) + .ok() + .and_then(|index| mesh.vertices.get(index)) + else { + return false; + }; + let [v0, v1, v2] = [v0.position, v1.position, v2.position]; + let face = cross3(sub3(v1, v0), sub3(v2, v0)); + let facing = dot3(sub3(eye_world, v0), face) * f32::from(handedness); + facing.is_finite() && facing > 0.0 +} + +fn world_draw_range_index_count( + mesh: &VulkanStaticMesh, + range: &VulkanStaticDrawRange, + original_index_count: u32, + diffuse_alpha: f32, + eye_world: [f32; 3], + native_msh_handedness: Option<i8>, +) -> u32 { + if !(diffuse_alpha > 0.0) { + return 0; + } + if native_msh_handedness.is_some_and(|handedness| { + !native_msh_first_triangle_visible(mesh, range, eye_world, handedness) + }) { + return 0; + } + original_index_count +} + fn configure_world_material_categories( mesh: &mut VulkanStaticMesh, materials: &[VulkanStaticMaterial], @@ -1261,6 +1368,7 @@ fn append_environment_material( sky_nebula_stars: false, lightmap_mode: false, sky_far_depth: matches!(fallback_row, 0..=2), + screen_tl: ENVIRONMENT_SPRITE_ROWS.contains(&fallback_row), }); phase_material_indices.push(index); } @@ -1321,6 +1429,7 @@ fn append_environment_gradient_material( sky_nebula_stars: false, lightmap_mode: false, sky_far_depth: true, + screen_tl: false, }); Ok(index) } @@ -1371,6 +1480,7 @@ fn append_shadow_material(materials: &mut Vec<VulkanStaticMaterial>) -> Result<u sky_nebula_stars: false, lightmap_mode: false, sky_far_depth: false, + screen_tl: false, }); Ok(index) } @@ -1990,6 +2100,7 @@ impl EnvironmentGpuScene { fn new( mesh: &mut VulkanStaticMesh, + native_handedness_by_range: &mut HashMap<usize, i8>, materials: &mut Vec<VulkanStaticMaterial>, preview_material_animations: &mut Vec<MaterialAnimationBinding>, sun_occlusion_range_indices: &mut Vec<usize>, @@ -2409,6 +2520,14 @@ impl EnvironmentGpuScene { old_order.extend(sprite_indices.iter().copied()); old_order.extend([rain_index, snow_index, lightning_index]); let remap = reorder_draw_ranges(mesh, &old_order)?; + let mut remapped_handedness = HashMap::with_capacity(native_handedness_by_range.len()); + for (old_range_index, handedness) in std::mem::take(native_handedness_by_range) { + let new_range_index = remap.get(old_range_index).copied().ok_or_else(|| { + format!("native MSH draw range remap is missing range {old_range_index}") + })?; + remapped_handedness.insert(new_range_index, handedness); + } + *native_handedness_by_range = remapped_handedness; for layer in &mut scene.sky_layers { layer.range.range_index = *remap .get(layer.range.range_index) @@ -2648,17 +2767,33 @@ impl EnvironmentGpuScene { viewport, horizontal_fov(camera, viewport[0] / viewport[1].max(1.0)), ) { - vertices = Some(sprite_quad_vertices_pixels( - camera, - frame.direction, - (camera.far_plane * 0.5).max(camera.near_plane * 2.0), - half_width, - half_height, - viewport, - frame.color, - frame.alpha, - frame.uv, - )); + if ENVIRONMENT_SPRITE_ROWS.contains(&sprite.row) { + if let Some(center_pixels) = projected { + vertices = Some(screen_tl_quad_vertices_pixels( + center_pixels, + half_width, + half_height, + viewport, + frame.color, + frame.alpha, + frame.uv, + )); + } else { + active = false; + } + } else { + vertices = Some(sprite_quad_vertices_pixels( + camera, + frame.direction, + (camera.far_plane * 0.5).max(camera.near_plane * 2.0), + half_width, + half_height, + viewport, + frame.color, + frame.alpha, + frame.uv, + )); + } } else { active = false; } @@ -3083,6 +3218,50 @@ fn sprite_quad_vertices_pixels( ] } +fn screen_tl_quad_vertices_pixels( + center_pixels: [f32; 2], + half_width_pixels: f32, + half_height_pixels: f32, + viewport: [f32; 2], + color: [f32; 3], + alpha: f32, + uv: [[f32; 2]; 4], +) -> [VulkanStaticVertex; 4] { + let width = viewport[0].max(1.0); + let height = viewport[1].max(1.0); + let half_width = half_width_pixels.max(0.0); + let half_height = half_height_pixels.max(0.0); + let pixel_corners = [ + [ + center_pixels[0] - half_width, + center_pixels[1] + half_height, + ], + [ + center_pixels[0] + half_width, + center_pixels[1] + half_height, + ], + [ + center_pixels[0] - half_width, + center_pixels[1] - half_height, + ], + [ + center_pixels[0] + half_width, + center_pixels[1] - half_height, + ], + ]; + // Positive-height Vulkan viewports map NDC -Y to top-down screen pixels. + let uv = [uv[1], uv[2], uv[0], uv[3]]; + std::array::from_fn(|index| { + let pixel = pixel_corners[index]; + let position = [ + pixel[0] * (2.0 / width) - 1.0, + pixel[1] * (2.0 / height) - 1.0, + 0.999, + ]; + environment_vertex_with_alpha(position, color, alpha, uv[index]) + }) +} + fn precipitation_quad_vertices( kind: PrecipitationKind, screen: &ScreenBillboard, @@ -3594,6 +3773,7 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { struct StaticPreviewScene { mesh: VulkanStaticMesh, + native_handedness_by_range: HashMap<usize, i8>, materials: Vec<VulkanStaticMaterial>, material_animations: Vec<MaterialAnimationBinding>, sun_occlusion_range_indices: Vec<usize>, @@ -3661,6 +3841,11 @@ const PROTOTYPE_TYPE_FORT: u32 = u32::from_le_bytes(*b"FORT"); const PROTOTYPE_TYPE_BTLU: u32 = u32::from_le_bytes(*b"BTLU"); const PROTOTYPE_TYPE_STAT: u32 = u32::from_le_bytes(*b"STAT"); +// shortcut: the preview does not load shade.cfg yet, so use CShade's native +// defaults until the settings loader is implemented. +const NATIVE_DEFAULT_ROB_SHADOWS_ON: bool = true; +const NATIVE_DEFAULT_BLD_SHADOWS_ON: bool = false; + /// Maps the resolved registry type to the native object-kind selector used by /// CSun/CShade. The mission object's raw kind is an unrelated TMA field; the /// renderer receives the registry provenance from the prepared visual. @@ -3673,6 +3858,14 @@ fn native_shadow_kind(source_type: Option<u32>) -> Option<u32> { } } +fn native_shadow_caster_enabled(native_kind: u32) -> bool { + if native_kind == 4 { + NATIVE_DEFAULT_ROB_SHADOWS_ON + } else { + NATIVE_DEFAULT_BLD_SHADOWS_ON + } +} + fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNodePose { let rotated = rotate_unit_vector(child.translation, parent.rotation); VulkanNodePose { @@ -3805,6 +3998,7 @@ fn static_preview_mesh_and_materials( indices: Vec::new(), draw_ranges: Vec::new(), }; + let mut native_handedness_by_range = HashMap::new(); let mut materials = Vec::new(); let mut material_animations = Vec::new(); let mut sun_occlusion_range_indices = Vec::new(); @@ -4003,7 +4197,7 @@ fn static_preview_mesh_and_materials( }; if should_render { let pose_buffer = component_pose_buffers[component_index].as_ref(); - let component = match (pose_buffer, mount_pose, static_animation_frame) { + let mut component = match (pose_buffer, mount_pose, static_animation_frame) { (Some(poses), Some(mount), _) => { project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( &model.validated, @@ -4055,6 +4249,11 @@ fn static_preview_mesh_and_materials( } } .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; + let component_handedness = native_msh_handedness(transform, mission_root.scale); + for range in &mut component.draw_ranges { + range.pipeline_state.cull = + native_msh_cull_mode(range.batch_flags, component_handedness); + } let component_shadow_triangles = static_mesh_shadow_triangles(&component); if root_native_kind.is_some() { root_shadow_triangles.extend(component_shadow_triangles); @@ -4093,13 +4292,18 @@ fn static_preview_mesh_and_materials( &lightmap_textures, Some(&mut material_animations), )?; + for range_index in range_start..mesh.draw_ranges.len() { + native_handedness_by_range.insert(range_index, component_handedness); + } if root_sun_eligible { sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len()); } mesh_components += 1; } } - if let Some(native_kind) = root_native_kind { + if let Some(native_kind) = + root_native_kind.filter(|kind| native_shadow_caster_enabled(*kind)) + { let caster_id = u32::try_from(shadow_casters.len()) .map_err(|_| "shadow caster id exceeds u32".to_string())?; if let Some(caster) = aggregate_shadow_caster( @@ -4140,6 +4344,7 @@ fn static_preview_mesh_and_materials( let shadow_material_index = append_shadow_material(&mut materials)?; Ok(StaticPreviewScene { mesh, + native_handedness_by_range, materials, material_animations, sun_occlusion_range_indices, @@ -4305,6 +4510,7 @@ fn static_preview_terrain_base_materials( sky_nebula_stars: false, lightmap_mode: false, sky_far_depth: false, + screen_tl: false, }); phase_material_indices.push(usize::from(preview_selector)); } @@ -4510,6 +4716,7 @@ fn static_preview_component_materials( sky_nebula_stars: false, lightmap_mode: false, sky_far_depth: false, + screen_tl: false, }); phase_material_indices.push(usize::from(preview_selector)); } @@ -4863,6 +5070,7 @@ fn run_static_vulkan_mode( let sky_mesh = environment.sky.mesh().clone(); Some(EnvironmentGpuScene::new( &mut preview.mesh, + &mut preview.native_handedness_by_range, &mut preview.materials, &mut preview.material_animations, &mut preview.sun_occlusion_range_indices, @@ -4900,6 +5108,7 @@ fn run_static_vulkan_mode( struct StaticVulkanApp { mesh: VulkanStaticMesh, + native_handedness_by_range: HashMap<usize, i8>, materials: Vec<VulkanStaticMaterial>, material_animations: Vec<MaterialAnimationBinding>, sun_occlusion_range_indices: Vec<usize>, @@ -4980,6 +5189,7 @@ impl StaticVulkanApp { .collect(); Self { mesh: preview.mesh, + native_handedness_by_range: preview.native_handedness_by_range, materials: preview.materials, material_animations: preview.material_animations, sun_occlusion_range_indices: preview.sun_occlusion_range_indices, @@ -5058,7 +5268,14 @@ impl StaticVulkanApp { renderer .set_draw_range_index_count( range_index, - if alpha > 0.0 { range.index_count } else { 0 }, + world_draw_range_index_count( + &self.mesh, + range, + range.index_count, + alpha, + self.view_camera.position, + self.native_handedness_by_range.get(&range_index).copied(), + ), ) .map_err(|error| { format!("configure world draw range {range_index} count: {error}") @@ -5072,6 +5289,8 @@ impl StaticVulkanApp { materials: &[VulkanStaticMaterial], world_range_indices: &[usize], world_range_index_counts: &HashMap<usize, u32>, + native_handedness_by_range: &HashMap<usize, i8>, + eye_world: [f32; 3], active_materials: &HashMap<usize, ActiveMaterialState>, renderer: &mut VulkanSmokeRenderer, ) -> Result<(), String> { @@ -5092,13 +5311,17 @@ impl StaticVulkanApp { renderer .set_draw_range_transparency(range_index, native_world_transparent(range, alpha)) .map_err(|error| format!("update world draw range {range_index}: {error}"))?; - let count = if alpha > 0.0 { - *world_range_index_counts - .get(&range_index) - .ok_or_else(|| "world draw range count is missing".to_string())? - } else { - 0 - }; + let original_index_count = *world_range_index_counts + .get(&range_index) + .ok_or_else(|| "world draw range count is missing".to_string())?; + let count = world_draw_range_index_count( + mesh, + range, + original_index_count, + alpha, + eye_world, + native_handedness_by_range.get(&range_index).copied(), + ); renderer .set_draw_range_index_count(range_index, count) .map_err(|error| format!("update world draw range {range_index} count: {error}"))?; @@ -5257,6 +5480,8 @@ impl StaticVulkanApp { &self.materials, &self.world_range_indices, &self.world_range_index_counts, + &self.native_handedness_by_range, + self.view_camera.position, &active_materials, renderer, )?; @@ -6229,10 +6454,13 @@ mod tests { } #[test] - fn native_sun_object_source_types_map_to_world_kinds() { + fn native_shadow_kind_mapping_keeps_sun_eligibility_separate_from_caster_defaults() { assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_FORT)), Some(3)); assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_BTLU)), Some(4)); assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_STAT)), Some(10)); + assert!(!native_shadow_caster_enabled(3)); + assert!(native_shadow_caster_enabled(4)); + assert!(!native_shadow_caster_enabled(10)); assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_FORT))); assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_BTLU))); assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_STAT))); @@ -7336,4 +7564,302 @@ mod tests { [uv[1], uv[2], uv[0], uv[3]] ); } + + #[test] + fn native_tl_sprite_pixels_use_current_viewport_and_preserve_depth_999() { + let uv = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]; + let small = screen_tl_quad_vertices_pixels( + [160.0, 120.0], + 40.0, + 30.0, + [640.0, 480.0], + [1.0; 3], + 1.0, + uv, + ); + let resized = screen_tl_quad_vertices_pixels( + [320.0, 240.0], + 80.0, + 60.0, + [1280.0, 960.0], + [1.0; 3], + 1.0, + uv, + ); + let wide = screen_tl_quad_vertices_pixels( + [640.0, 360.0], + 100.0, + 45.0, + [1280.0, 720.0], + [1.0; 3], + 1.0, + uv, + ); + + for (small, resized) in small.iter().zip(resized) { + for axis in 0..2 { + assert!((small.position[axis] - resized.position[axis]).abs() < 1.0e-6); + } + assert_eq!(small.position[2], 0.999); + assert_eq!(resized.position[2], 0.999); + } + assert_eq!(small[0].uv, [0.0, 1.0]); + assert_eq!(small[2].uv, [0.0, 0.0]); + for (actual, expected) in [ + (wide[0].position, [-0.15625, 0.125, 0.999]), + (wide[2].position, [-0.15625, -0.125, 0.999]), + ] { + for axis in 0..3 { + assert!((actual[axis] - expected[axis]).abs() < 1.0e-6); + } + } + } + + #[test] + fn native_tl_depth_999_is_nearer_than_opaque_geometry_at_320_units() { + let camera = FreeFlightCamera { + position: [0.0; 3], + yaw: 0.0, + pitch: 0.0, + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: 1.0, + }; + let frame = camera.preview_frame(16.0 / 9.0); + let opaque_clip = row_vector_transform([320.0, 0.0, 0.0, 1.0], frame.clip_from_world); + let opaque_depth = opaque_clip[2] / opaque_clip[3]; + + assert!((opaque_depth - 0.999151_18).abs() < 1.0e-6); + assert!(0.999 < opaque_depth); + } + + fn facing_mesh( + batch_flags: u16, + positions: &[[f32; 3]], + ) -> (VulkanStaticMesh, VulkanStaticDrawRange) { + let range = VulkanStaticDrawRange { + first_index: 0, + index_count: u32::try_from(positions.len()).expect("fixture index count fits u32"), + material_index: 0, + lightmap_index: 0, + batch_flags, + pipeline_state: LegacyPipelineState::default(), + alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, + }; + let mesh = VulkanStaticMesh { + vertices: positions + .iter() + .copied() + .map(|position| VulkanStaticVertex { + position, + color: [1.0; 3], + normal: [0.0, 0.0, 1.0], + uv: [0.0; 2], + detail_uv: [0.0; 2], + overlay_alpha: 0.0, + }) + .collect(), + indices: (0..positions.len()) + .map(|index| u32::try_from(index).expect("fixture index fits u32")) + .collect(), + draw_ranges: vec![range], + }; + (mesh, range) + } + + fn native_front_triangle() -> [[f32; 3]; 3] { + [[-0.2, -0.2, -1.0], [0.2, -0.2, -1.0], [0.0, 0.2, -1.0]] + } + + fn camera_face_triangle(camera: PreviewCameraFrame, mirror: bool) -> [[f32; 3]; 3] { + let center = add3(camera.position, scale3(camera.forward, 10.0)); + let mirror = if mirror { -1.0 } else { 1.0 }; + [ + add3( + add3(center, scale3(camera.right, -2.0 * mirror)), + scale3(camera.up, -2.0), + ), + add3( + add3(center, scale3(camera.right, 2.0 * mirror)), + scale3(camera.up, -2.0), + ), + add3(center, scale3(camera.up, 2.0)), + ] + } + + fn projected_vulkan_area(camera: PreviewCameraFrame, triangle: [[f32; 3]; 3]) -> f32 { + let screen = triangle.map(|point| { + let clip = + row_vector_transform([point[0], point[1], point[2], 1.0], camera.clip_from_world); + assert!(clip[3] > 0.0 && clip.iter().all(|value| value.is_finite())); + // This is the positive-height viewport transform used by the Vulkan renderer. + [ + (clip[0] / clip[3] + 1.0) * 0.5, + (clip[1] / clip[3] + 1.0) * 0.5, + ] + }); + let shoelace = (0..3) + .map(|index| { + let next = (index + 1) % 3; + screen[index][0] * screen[next][1] - screen[next][0] * screen[index][1] + }) + .sum::<f32>() + * 0.5; + // Vulkan's framebuffer signed-area definition is the negative shoelace area. + -shoelace + } + + #[test] + fn native_msh_cull_mode_matches_legacy_and_free_flight_camera_projection() { + let identity = RawCameraTransform { + words: [ + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + ], + }; + let projection = LegacyD3d7Projection { + viewport: [0, 0, 1280, 1024], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: 1.3, + }; + let legacy = PreviewCameraFrame::from_legacy_d3d7(identity, projection) + .expect("valid legacy camera projection"); + let free_flight = FreeFlightCamera { + position: legacy.position, + yaw: 0.0, + pitch: 0.0, + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: 1.0, + } + .preview_frame(1280.0 / 1024.0); + + for camera in [legacy, free_flight] { + let front = camera_face_triangle(camera, false); + let back = [front[0], front[2], front[1]]; + let front_d = dot3( + sub3(camera.position, front[0]), + cross3(sub3(front[1], front[0]), sub3(front[2], front[0])), + ); + let back_d = dot3( + sub3(camera.position, back[0]), + cross3(sub3(back[1], back[0]), sub3(back[2], back[0])), + ); + assert!(front_d > 0.0); + assert!(back_d < 0.0); + + let front_vk_area = projected_vulkan_area(camera, front); + let back_vk_area = projected_vulkan_area(camera, back); + // Vulkan CLOCKWISE front faces have negative framebuffer area. + // The native-visible d>0 face is therefore a Vulkan back face. + assert!(front_vk_area > 0.0, "front area: {front_vk_area}"); + assert!(back_vk_area < 0.0, "back area: {back_vk_area}"); + assert_eq!(native_msh_cull_mode(0, 1), LegacyCullMode::FrontFace); + + let mirrored_front = camera_face_triangle(camera, true); + let mirrored_d = dot3( + sub3(camera.position, mirrored_front[0]), + cross3( + sub3(mirrored_front[1], mirrored_front[0]), + sub3(mirrored_front[2], mirrored_front[0]), + ), + ); + assert!(mirrored_d < 0.0); + assert!(projected_vulkan_area(camera, mirrored_front) < 0.0); + assert_eq!(native_msh_cull_mode(0, -1), LegacyCullMode::BackFace); + } + } + + #[test] + fn native_msh_handedness_uses_full_placement_transform_and_handles_singular_scale() { + let rotated = LegacyIron3dEulerTransform { + translation: [8.0, -4.0, 3.0], + orientation_radians: [0.4, -0.7, 1.2], + }; + assert_eq!(native_msh_handedness(rotated, [2.0, 3.0, 4.0]), 1); + assert_eq!(native_msh_handedness(rotated, [-2.0, 3.0, 4.0]), -1); + assert_eq!(native_msh_handedness(rotated, [0.0, 3.0, 4.0]), 0); + assert_eq!(native_msh_cull_mode(0, 0), LegacyCullMode::Disabled); + } + + #[test] + fn native_msh_4000_samples_only_first_triangle_and_raw_two_sided_bypasses_it() { + let front = native_front_triangle(); + let back = front.map(|point| [-point[0], point[1], point[2]]); + let front_then_back = [front[0], front[1], front[2], back[0], back[1], back[2]]; + let (mesh, range) = facing_mesh(0x4000, &front_then_back); + assert!(native_msh_first_triangle_visible( + &mesh, &range, [0.0; 3], 1 + )); + + let back_then_front = [back[0], back[1], back[2], front[0], front[1], front[2]]; + let (mesh, range) = facing_mesh(0x4000, &back_then_front); + assert!(!native_msh_first_triangle_visible( + &mesh, &range, [0.0; 3], 1 + )); + + let (mesh, range) = facing_mesh(0x4002, &back_then_front); + assert!(native_msh_first_triangle_visible( + &mesh, &range, [0.0; 3], 1 + )); + assert_eq!( + native_msh_cull_mode(range.batch_flags, 1), + LegacyCullMode::Disabled + ); + } + + #[test] + fn world_draw_range_count_restores_after_alpha_and_msh_facing_changes() { + let triangle = native_front_triangle(); + let (mesh, range) = facing_mesh(0x4000, &triangle); + let original_count = range.index_count; + + assert_eq!( + world_draw_range_index_count(&mesh, &range, original_count, 0.0, [0.0; 3], Some(1)), + 0 + ); + assert_eq!( + world_draw_range_index_count( + &mesh, + &range, + original_count, + 1.0, + [0.0, 0.0, -2.0], + Some(1), + ), + 0 + ); + assert_eq!( + world_draw_range_index_count(&mesh, &range, original_count, 1.0, [0.0; 3], Some(1)), + original_count + ); + assert_eq!( + world_draw_range_index_count( + &mesh, + &range, + original_count, + 1.0, + [0.0, 0.0, -2.0], + None, + ), + original_count + ); + } } diff --git a/crates/fparkan-fx/src/ambient_op3.rs b/crates/fparkan-fx/src/ambient_op3.rs new file mode 100644 index 0000000..5bd2881 --- /dev/null +++ b/crates/fparkan-fx/src/ambient_op3.rs @@ -0,0 +1,984 @@ +//! Native-shaped CPU evaluation for Types 0, 1, and 2 carried by opcode 3. +//! +//! The FX body and the command opcode are separate concepts: this evaluator +//! accepts opcode 3 only when its body discriminator matches the selected +//! evaluator. Types 0/1/2 share the position, scale, material-phase, and +//! opacity tracks; Types 1/2 additionally use an animated axis. Their render +//! basis is selected by the native body discriminator. + +use crate::environment::EffectResource; +use crate::fx_visual::{ + compose_native_effect_matrix, native_camera_facing_basis, native_d110_basis, + native_effect_local_point, transform_native_effect_quad, EffectQuadFrame, EFFECT_QUAD_UV, +}; +use crate::{FxCommand, FxOpcode}; + +const TYPE2_BODY_DISCRIMINATOR: u32 = 2; +const TYPE2_ACTIVE_START_OFFSET: usize = 0x1c; +const TYPE2_ACTIVE_END_OFFSET: usize = 0x20; +const TYPE2_PHASE_START_OFFSET: usize = 0x04; +const TYPE2_PHASE_END_OFFSET: usize = 0x08; +const TYPE2_PHASE_EXPONENT_OFFSET: usize = 0x0c; +const TYPE2_OPACITY_START_OFFSET: usize = 0x10; +const TYPE2_OPACITY_END_OFFSET: usize = 0x14; +const TYPE2_OPACITY_EXPONENT_OFFSET: usize = 0x18; +const TYPE2_POSITION_START_OFFSET: usize = 0x24; +const TYPE2_POSITION_END_OFFSET: usize = 0x30; +#[cfg(test)] +const TYPE2_POSITION_EXPONENT_OFFSET: usize = 0x3c; +const TYPE2_AXIS_START_OFFSET: usize = 0x48; +const TYPE2_AXIS_END_OFFSET: usize = 0x54; +const TYPE2_SCALE_START_OFFSET: usize = 0x60; +const TYPE2_SCALE_END_OFFSET: usize = 0x6c; +const TYPE2_RESOURCE_OFFSET: usize = 0x84; +const TYPE2_RESOURCE_BYTES: usize = 64; +const TYPE0_BODY_DISCRIMINATOR: u32 = 0; +const TYPE1_BODY_DISCRIMINATOR: u32 = 1; + +/// Per-frame values sampled from the verified Op3 Type0 body tracks. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Op3Type0Sample { + /// Animated local translation sampled from body+0x24/+0x30 with exponents + /// at body+0x3c/+0x40/+0x44. + position: [f32; 3], + /// Animated scale sampled from body+0x60/+0x6c with reverse-order + /// exponents at body+0x78/+0x7c/+0x80. + scale: [f32; 3], + /// Native command opacity after its authored curve. + opacity: f32, + /// Wrapped texture phase computed by the native Op3 time track. + material_phase: f32, +} + +/// Per-frame values sampled from the verified native Op3 Type2 body tracks. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Op3Type2Sample { + /// Animated local translation used by native helper 0xC5D0. + position: [f32; 3], + /// Animated local axis used by native helper 0xD110. + axis: [f32; 3], + /// Animated scale applied to the parent matrix basis. + scale: [f32; 3], + /// Opacity multiplier sent through native helper 0x9930 and clamped to + /// `[0, 1]` for the material descriptor. + opacity: f32, + /// Wrapped texture phase computed by the native Op3 time track. + material_phase: f32, +} + +/// Per-frame values sampled from the same track layout in a Type 1 body. +type Op3Type1Sample = Op3Type2Sample; + +/// Evaluates one FX opcode 3 / Type 0 fixed camera-facing command. +/// +/// Type 0 uses the shared animated position and scale tracks but forms its +/// local basis from the camera vector. The current caller supplies the +/// already-composed Control owner transform and header scale. +/// The supported caller path assumes native render-context index 0, which +/// satisfies Effect.dll's shared `context + 0x60 < 8` gate. +#[must_use] +pub fn evaluate_op3_type0_command( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + elapsed_seconds: f32, + effect_phase: f32, + camera_world: [f32; 3], +) -> Option<EffectQuadFrame> { + let body = &command.raw_body; + if command.opcode != FxOpcode::Op3 + || read_u32(body, 0)? != TYPE0_BODY_DISCRIMINATOR + || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES + || !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !elapsed_seconds.is_finite() + || !effect_phase.is_finite() + || !camera_world.iter().all(|value| value.is_finite()) + { + return None; + } + + let start = read_f32(body, TYPE2_ACTIVE_START_OFFSET)?; + let end = read_f32(body, TYPE2_ACTIVE_END_OFFSET)?; + if !start.is_finite() || !end.is_finite() || start > end { + return None; + } + + let resource = read_resource(command)?; + let normalized_time = interval_phase(effect_phase, start, end); + let sample = sample_op3_type0_body(body, normalized_time, elapsed_seconds)?; + let camera_local = native_effect_local_point(world_from_effect, camera_world)?; + let view = sub3(camera_local, sample.position); + let local_basis = native_camera_facing_basis(view, sample.position)?; + let matrix = compose_native_effect_matrix( + world_from_effect, + &local_basis, + sample.position, + sample.scale, + )?; + let vertices = transform_native_effect_quad(&matrix)?; + + Some(EffectQuadFrame { + command_index, + opcode: FxOpcode::Op3, + material: resource, + vertices, + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, sample.opacity], + elapsed_seconds, + normalized_time, + material_phase: sample.material_phase, + active: effect_phase >= start && effect_phase <= end, + native_flag_base: None, + }) +} + +/// Evaluates one FX opcode 3 / Type 2 command using native helper matrix rules. +/// +/// `world_from_effect` is the row-major parent descriptor already composed by +/// the caller through the selected CPT row, mission-root transform, resolved +/// owner pose (including a mounted component pose), and FX header scale. The +/// evaluator does not resolve those inputs itself. The matrix is the parent +/// supplied to native helper 0x93D0, not the final quad matrix. +/// +/// `effect_phase` is the header-transformed, command-clamped overall effect +/// phase in `[0, 1]`. The command's inclusive phase window is stored at +/// raw-body offsets `0x1c` and `0x20`. +/// `render_context` mirrors the native 0x106C0 gate (`context + 0x60 < 8`). +/// The function returns `None` for another command/body type, malformed +/// resources, non-finite inputs, or a degenerate non-finite transform. +#[must_use] +pub fn evaluate_op3_type2_command( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + elapsed_seconds: f32, + effect_phase: f32, + render_context: u32, +) -> Option<EffectQuadFrame> { + if command.opcode != FxOpcode::Op3 + || read_u32(&command.raw_body, 0)? != TYPE2_BODY_DISCRIMINATOR + || command.raw_body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES + || !elapsed_seconds.is_finite() + || !effect_phase.is_finite() + { + return None; + } + + let start = read_f32(&command.raw_body, TYPE2_ACTIVE_START_OFFSET)?; + let end = read_f32(&command.raw_body, TYPE2_ACTIVE_END_OFFSET)?; + if !start.is_finite() || !end.is_finite() || start > end { + return None; + } + + let resource = read_resource(command)?; + let normalized_time = interval_phase(effect_phase, start, end); + let sample = sample_op3_type2_body(&command.raw_body, normalized_time, elapsed_seconds)?; + let matrix = native_op3_type2_matrix(world_from_effect, sample)?; + let vertices = transform_native_effect_quad(&matrix)?; + + Some(EffectQuadFrame { + command_index, + opcode: FxOpcode::Op3, + material: resource, + vertices, + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, sample.opacity], + elapsed_seconds, + normalized_time, + material_phase: sample.material_phase, + active: render_context < 8 && effect_phase >= start && effect_phase <= end, + native_flag_base: None, + }) +} + +/// Evaluates one FX opcode 3 / Type 1 camera-facing command. +/// +/// Type 1 uses the native camera-to-position vector and sampled axis to form +/// its quad basis. The command's body tracks use the same offsets as Type 2. +/// The supported caller path assumes native render-context index 0, which +/// satisfies the Effect.dll `context + 0x60 < 8` gate. +#[must_use] +pub fn evaluate_op3_type1_command( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + elapsed_seconds: f32, + effect_phase: f32, + camera_world: [f32; 3], +) -> Option<EffectQuadFrame> { + let body = &command.raw_body; + if command.opcode != FxOpcode::Op3 + || read_u32(body, 0)? != TYPE1_BODY_DISCRIMINATOR + || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES + || !elapsed_seconds.is_finite() + || !effect_phase.is_finite() + || !camera_world.iter().all(|value| value.is_finite()) + { + return None; + } + + let start = read_f32(body, TYPE2_ACTIVE_START_OFFSET)?; + let end = read_f32(body, TYPE2_ACTIVE_END_OFFSET)?; + if !start.is_finite() || !end.is_finite() || start > end { + return None; + } + + let resource = read_resource(command)?; + let normalized_time = interval_phase(effect_phase, start, end); + let sample = sample_op3_type1_body(body, normalized_time, elapsed_seconds)?; + let matrix = native_op3_type1_matrix(world_from_effect, sample, camera_world)?; + let vertices = transform_native_effect_quad(&matrix)?; + + Some(EffectQuadFrame { + command_index, + opcode: FxOpcode::Op3, + material: resource, + vertices, + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, sample.opacity], + elapsed_seconds, + normalized_time, + material_phase: sample.material_phase, + active: effect_phase >= start && effect_phase <= end, + native_flag_base: None, + }) +} + +/// Samples the verified Type2 body tracks used by native Op3 update/render. +/// +/// `normalized_active_time` is the value produced by the inclusive command +/// interval gate. Position and scale use component-wise powers; axis is a +/// linear start/end track. The separate phase track uses elapsed seconds when +/// its raw start is negative, matching native 0x105F0. +#[must_use] +fn sample_op3_type2_body( + body: &[u8], + normalized_active_time: f32, + elapsed_seconds: f32, +) -> Option<Op3Type2Sample> { + if read_u32(body, 0)? != TYPE2_BODY_DISCRIMINATOR + || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES + { + return None; + } + + sample_op3_body_tracks(body, normalized_active_time, elapsed_seconds) +} + +/// Samples the verified Op3 Type0 position/scale and common command tracks. +#[must_use] +fn sample_op3_type0_body( + body: &[u8], + normalized_active_time: f32, + elapsed_seconds: f32, +) -> Option<Op3Type0Sample> { + if read_u32(body, 0)? != TYPE0_BODY_DISCRIMINATOR + || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES + || !normalized_active_time.is_finite() + || !elapsed_seconds.is_finite() + { + return None; + } + + // Native 0x106C0 samples the record's position into helper+0x78 from the + // body position endpoints and exponents. The body tail at +0x78 is the + // reverse-ordered scale exponent vector, not a fixed position. + let position = sample_power_vector( + body, + TYPE2_POSITION_START_OFFSET, + TYPE2_POSITION_END_OFFSET, + [0x3c, 0x40, 0x44], + normalized_active_time, + )?; + let scale = sample_power_vector( + body, + TYPE2_SCALE_START_OFFSET, + TYPE2_SCALE_END_OFFSET, + [0x80, 0x7c, 0x78], + normalized_active_time, + )?; + let (material_phase, opacity) = + sample_op3_material_phase_and_opacity(body, normalized_active_time, elapsed_seconds)?; + Some(Op3Type0Sample { + position, + scale, + opacity, + material_phase, + }) +} + +/// Samples the verified Op3 Type 1 body tracks. +#[must_use] +fn sample_op3_type1_body( + body: &[u8], + normalized_active_time: f32, + elapsed_seconds: f32, +) -> Option<Op3Type1Sample> { + if read_u32(body, 0)? != TYPE1_BODY_DISCRIMINATOR + || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES + { + return None; + } + + sample_op3_body_tracks(body, normalized_active_time, elapsed_seconds) +} + +fn sample_op3_body_tracks( + body: &[u8], + normalized_active_time: f32, + elapsed_seconds: f32, +) -> Option<Op3Type2Sample> { + if !normalized_active_time.is_finite() || !elapsed_seconds.is_finite() { + return None; + } + + let position = sample_power_vector( + body, + TYPE2_POSITION_START_OFFSET, + TYPE2_POSITION_END_OFFSET, + [0x3c, 0x40, 0x44], + normalized_active_time, + )?; + let axis = sample_linear_vector( + body, + TYPE2_AXIS_START_OFFSET, + TYPE2_AXIS_END_OFFSET, + normalized_active_time, + )?; + let scale = sample_power_vector( + body, + TYPE2_SCALE_START_OFFSET, + TYPE2_SCALE_END_OFFSET, + // 0x106C0 loads Z then Y then X from the packed reverse-order + // exponent vector at body+0x78. + [0x80, 0x7c, 0x78], + normalized_active_time, + )?; + + let (material_phase, opacity) = + sample_op3_material_phase_and_opacity(body, normalized_active_time, elapsed_seconds)?; + if !material_phase.is_finite() || !opacity.is_finite() { + return None; + } + + Some(Op3Type2Sample { + position, + axis, + scale, + opacity, + material_phase, + }) +} + +fn sample_op3_material_phase_and_opacity( + body: &[u8], + normalized_active_time: f32, + elapsed_seconds: f32, +) -> Option<(f32, f32)> { + let raw_phase_start = read_f32(body, TYPE2_PHASE_START_OFFSET)?; + let phase_end = read_f32(body, TYPE2_PHASE_END_OFFSET)?; + let phase_exponent = read_f32(body, TYPE2_PHASE_EXPONENT_OFFSET)?; + let opacity_start = read_f32(body, TYPE2_OPACITY_START_OFFSET)?; + let opacity_end = read_f32(body, TYPE2_OPACITY_END_OFFSET)?; + let opacity_exponent = read_f32(body, TYPE2_OPACITY_EXPONENT_OFFSET)?; + if ![ + raw_phase_start, + phase_end, + phase_exponent, + opacity_start, + opacity_end, + opacity_exponent, + normalized_active_time, + elapsed_seconds, + ] + .iter() + .all(|value| value.is_finite()) + { + return None; + } + + let phase_start = raw_phase_start.max(0.0); + let phase_delta = phase_end - phase_start; + let phase_timebase = if raw_phase_start < 0.0 { + elapsed_seconds + } else { + normalized_active_time + }; + let material_phase = fract(phase_start + phase_delta * phase_timebase.powf(phase_exponent)); + let opacity = (opacity_start + + (opacity_end - opacity_start) * normalized_active_time.powf(opacity_exponent)) + .clamp(0.0, 1.0); + (material_phase.is_finite() && opacity.is_finite()).then_some((material_phase, opacity)) +} + +/// Builds the native Type2 Op3 quad matrix from its parent descriptor and +/// already-sampled animated vectors. +/// +/// This matches 0x93D0's order: scale the parent basis by animated scale, +/// multiply by D110's local basis, and then replace the translation with the +/// unscaled parent transform of the animated position. +#[must_use] +fn native_op3_type2_matrix( + world_from_effect: &[[f32; 4]; 4], + sample: Op3Type2Sample, +) -> Option<[[f32; 4]; 4]> { + if !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !sample_is_finite(sample) + { + return None; + } + + let local = native_d110_basis(sample.axis, sample.position)?; + compose_native_effect_matrix(world_from_effect, &local, sample.position, sample.scale) +} + +/// Builds the native Type 1 Op3 quad matrix from the parent transform, sampled +/// tracks, and camera query point. +#[must_use] +fn native_op3_type1_matrix( + world_from_effect: &[[f32; 4]; 4], + sample: Op3Type1Sample, + camera_world: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + if !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !camera_world.iter().all(|value| value.is_finite()) + || !sample_is_finite(sample) + { + return None; + } + + let camera_local = native_effect_local_point(world_from_effect, camera_world)?; + let view = sub3(camera_local, sample.position); + let local_basis = native_op3_type1_basis(view, sample.axis, sample.position)?; + compose_native_effect_matrix( + world_from_effect, + &local_basis, + sample.position, + sample.scale, + ) +} + +/// Native Effect.dll Type 1 basis. The parallel gate compares the raw +/// `view · axis` value to exactly ±1 before building the cross-product frame; +/// neither input vector is normalized. The native path normalizes only +/// `view × axis`, then computes `axis × U` without normalizing the result. +#[must_use] +fn native_op3_type1_basis( + view: [f32; 3], + axis: [f32; 3], + position: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + if !view + .iter() + .chain(axis.iter()) + .chain(position.iter()) + .all(|value| value.is_finite()) + { + return None; + } + + let dot = view[0] * axis[0] + view[1] * axis[1] + view[2] * axis[2]; + if !dot.is_finite() || dot.abs() == 1.0 { + return None; + } + + let mut u = cross3(view, axis); + let length = length3(u)?; + if length == 0.0 { + return None; + } + for value in &mut u { + *value /= length; + } + let w = cross3(axis, u); + let result = [ + [u[0], w[0], axis[0], position[0]], + [u[1], w[1], axis[1], position[1]], + [u[2], w[2], axis[2], position[2]], + [0.0, 0.0, 0.0, 1.0], + ]; + result + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(result) +} + +fn interval_phase(phase: f32, start: f32, end: f32) -> f32 { + if phase < start { + return 0.0; + } + if phase > end { + return 1.0; + } + if end == start { + 1.0 + } else if phase == start { + 0.0 + } else if phase == end { + 1.0 + } else { + ((phase - start) / (end - start)).clamp(0.0, 1.0) + } +} + +fn sample_is_finite(sample: Op3Type2Sample) -> bool { + sample + .position + .iter() + .chain(sample.axis.iter()) + .chain(sample.scale.iter()) + .chain([&sample.opacity, &sample.material_phase]) + .all(|value| value.is_finite()) +} + +fn sample_linear_vector(body: &[u8], start: usize, end: usize, time: f32) -> Option<[f32; 3]> { + let start = read_vec3(body, start)?; + let end = read_vec3(body, end)?; + if !start + .iter() + .chain(end.iter()) + .all(|value| value.is_finite()) + || !time.is_finite() + { + return None; + } + let values = std::array::from_fn(|axis| start[axis] + (end[axis] - start[axis]) * time); + values + .iter() + .all(|value| value.is_finite()) + .then_some(values) +} + +fn sample_power_vector( + body: &[u8], + start: usize, + end: usize, + exponent_offsets: [usize; 3], + time: f32, +) -> Option<[f32; 3]> { + let start = read_vec3(body, start)?; + let end = read_vec3(body, end)?; + let exponents = [ + read_f32(body, exponent_offsets[0])?, + read_f32(body, exponent_offsets[1])?, + read_f32(body, exponent_offsets[2])?, + ]; + if !start + .iter() + .chain(end.iter()) + .chain(exponents.iter()) + .all(|value| value.is_finite()) + || !time.is_finite() + { + return None; + } + let values = std::array::from_fn(|axis| { + let amount = time.powf(exponents[axis]); + start[axis] + (end[axis] - start[axis]) * amount + }); + values + .iter() + .all(|value| value.is_finite()) + .then_some(values) +} + +fn read_vec3(body: &[u8], offset: usize) -> Option<[f32; 3]> { + Some([ + read_f32(body, offset)?, + read_f32(body, offset + 4)?, + read_f32(body, offset + 8)?, + ]) +} + +fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] +} + +fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn length3(vector: [f32; 3]) -> Option<f32> { + let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt(); + length.is_finite().then_some(length) +} + +fn fract(value: f32) -> f32 { + value - value.floor() +} + +fn read_resource(command: &FxCommand) -> Option<EffectResource> { + let bytes = command + .raw_body + .get(TYPE2_RESOURCE_OFFSET..TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES)?; + let archive = bounded_name(&bytes[..32])?; + let name = bounded_name(&bytes[32..])?; + (!archive.is_empty() && !name.is_empty()).then_some(EffectResource { archive, name }) +} + +fn bounded_name(bytes: &[u8]) -> Option<String> { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + let value = std::str::from_utf8(&bytes[..end]).ok()?.trim(); + (!value.is_empty()).then(|| value.to_owned()) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> { + Some(u32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> { + Some(f32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn type0_command_samples_position_and_scale_tracks_for_camera_facing_quad() { + let mut body = vec![0_u8; 196]; + write_u32(&mut body, 0, TYPE0_BODY_DISCRIMINATOR); + write_f32(&mut body, TYPE2_PHASE_START_OFFSET, -1.0); + write_f32(&mut body, TYPE2_PHASE_END_OFFSET, 1.0); + write_f32(&mut body, TYPE2_PHASE_EXPONENT_OFFSET, 1.0); + write_f32(&mut body, TYPE2_OPACITY_START_OFFSET, 1.0); + write_f32(&mut body, TYPE2_OPACITY_END_OFFSET, 0.0); + write_f32(&mut body, TYPE2_OPACITY_EXPONENT_OFFSET, 1.0); + write_f32(&mut body, TYPE2_ACTIVE_START_OFFSET, 0.0); + write_f32(&mut body, TYPE2_ACTIVE_END_OFFSET, 1.0); + write_vec3(&mut body, TYPE2_POSITION_START_OFFSET, [0.0, 0.0, 0.0]); + write_vec3(&mut body, TYPE2_POSITION_END_OFFSET, [2.0, 4.0, 6.0]); + write_vec3(&mut body, TYPE2_POSITION_EXPONENT_OFFSET, [1.0; 3]); + write_vec3(&mut body, TYPE2_SCALE_START_OFFSET, [1.0; 3]); + write_vec3(&mut body, TYPE2_SCALE_END_OFFSET, [3.0, 4.0, 5.0]); + write_vec3(&mut body, 0x78, [3.0, 2.0, 1.0]); + body[TYPE2_RESOURCE_OFFSET..TYPE2_RESOURCE_OFFSET + 11].copy_from_slice(b"effects.rlb"); + body[TYPE2_RESOURCE_OFFSET + 32..TYPE2_RESOURCE_OFFSET + 32 + 3].copy_from_slice(b"sun"); + + let command = FxCommand { + word: 3, + opcode: FxOpcode::Op3, + native_flag: false, + raw_body: Arc::from(body.into_boxed_slice()), + resource_refs: Vec::new(), + }; + let identity = [ + [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], + ]; + let sample = sample_op3_type0_body(&command.raw_body, 0.5, 0.25).unwrap(); + assert_eq!(sample.position, [1.0, 2.0, 3.0]); + assert_eq!(sample.scale, [2.0, 1.75, 1.5]); + assert_eq!(sample.opacity, 0.5); + assert_eq!(sample.material_phase, 0.25); + + let frame = evaluate_op3_type0_command(&command, 7, &identity, 0.25, 0.5, [1.0, 2.0, 13.0]) + .unwrap(); + assert_eq!(frame.command_index, 7); + assert_eq!(frame.material.name, "sun"); + assert_eq!(frame.normalized_time, 0.5); + assert_eq!(frame.material_phase, 0.25); + assert_eq!(frame.color[3], 0.5); + assert!(frame.active); + let center = std::array::from_fn::<_, 3, _>(|axis| { + frame + .vertices + .iter() + .map(|vertex| vertex[axis]) + .sum::<f32>() + / 4.0 + }); + assert_eq!(center, [1.0, 2.0, 3.0]); + assert_ne!(frame.vertices[0], frame.vertices[1]); + + assert!( + evaluate_op3_type0_command(&command, 7, &identity, 0.25, 1.1, [1.0, 2.0, 13.0],) + .is_some_and(|frame| !frame.active) + ); + assert!( + evaluate_op3_type0_command(&command, 7, &identity, 0.25, 0.5, [1.0, 2.0, 3.0],) + .is_none() + ); + } + + #[test] + fn type2_matrix_scales_parent_basis_then_overrides_translation() { + let parent = [ + [2.0, 0.0, 0.0, 10.0], + [0.0, 3.0, 0.0, 20.0], + [0.0, 0.0, 4.0, 30.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let sample = Op3Type2Sample { + position: [1.0, 2.0, 3.0], + axis: [1.0, 2.0, 2.0], + scale: [2.0, 3.0, 4.0], + opacity: 1.0, + material_phase: 0.0, + }; + let result = native_op3_type2_matrix(&parent, sample).unwrap(); + assert_eq!(result[0][3], 12.0); + assert_eq!(result[1][3], 26.0); + assert_eq!(result[2][3], 42.0); + assert_eq!(result[0][0], -8.0 / 5.0_f32.sqrt()); + assert_eq!(result[1][0], 9.0 / 5.0_f32.sqrt()); + assert_eq!(result[3], [0.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn type2_command_keeps_inclusive_single_phase_endpoint() { + let mut body = vec![0_u8; 196]; + body[0..4].copy_from_slice(&TYPE2_BODY_DISCRIMINATOR.to_le_bytes()); + body[TYPE2_ACTIVE_START_OFFSET..TYPE2_ACTIVE_START_OFFSET + 4] + .copy_from_slice(&1.0_f32.to_le_bytes()); + body[TYPE2_ACTIVE_END_OFFSET..TYPE2_ACTIVE_END_OFFSET + 4] + .copy_from_slice(&1.0_f32.to_le_bytes()); + body[TYPE2_RESOURCE_OFFSET..TYPE2_RESOURCE_OFFSET + 11].copy_from_slice(b"effects.rlb"); + body[TYPE2_RESOURCE_OFFSET + 32..TYPE2_RESOURCE_OFFSET + 36].copy_from_slice(b"lamp"); + let command = FxCommand { + word: 3, + opcode: FxOpcode::Op3, + native_flag: false, + raw_body: Arc::from(body.into_boxed_slice()), + resource_refs: Vec::new(), + }; + let identity = [ + [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], + ]; + let frame = evaluate_op3_type2_command(&command, 0, &identity, 1.0, 1.0, 0).unwrap(); + assert_eq!( + frame.material, + EffectResource { + archive: "effects.rlb".to_owned(), + name: "lamp".to_owned(), + } + ); + assert!(frame.active); + assert_eq!(frame.normalized_time, 1.0); + + let before = evaluate_op3_type2_command(&command, 0, &identity, 0.99, 0.99, 0).unwrap(); + assert!(!before.active); + } + + #[test] + fn samples_verified_type2_position_axis_scale_phase_and_opacity_tracks() { + let mut body = vec![0_u8; 196]; + write_u32(&mut body, 0, TYPE2_BODY_DISCRIMINATOR); + write_f32(&mut body, TYPE2_PHASE_START_OFFSET, -1.0); + write_f32(&mut body, TYPE2_PHASE_END_OFFSET, 9.0); + write_f32(&mut body, TYPE2_PHASE_EXPONENT_OFFSET, 1.0); + write_f32(&mut body, TYPE2_OPACITY_START_OFFSET, 2.0); + write_f32(&mut body, TYPE2_OPACITY_END_OFFSET, -1.0); + write_f32(&mut body, TYPE2_OPACITY_EXPONENT_OFFSET, 1.0); + write_vec3(&mut body, TYPE2_POSITION_START_OFFSET, [0.0, 0.0, 0.0]); + write_vec3(&mut body, TYPE2_POSITION_END_OFFSET, [8.0, 16.0, 24.0]); + write_vec3(&mut body, TYPE2_POSITION_EXPONENT_OFFSET, [1.0, 2.0, 3.0]); + write_vec3(&mut body, TYPE2_AXIS_START_OFFSET, [1.0, 2.0, 3.0]); + write_vec3(&mut body, TYPE2_AXIS_END_OFFSET, [5.0, 6.0, 7.0]); + write_vec3(&mut body, TYPE2_SCALE_START_OFFSET, [1.0; 3]); + write_vec3(&mut body, TYPE2_SCALE_END_OFFSET, [3.0; 3]); + write_f32(&mut body, 0x78, 3.0); + write_f32(&mut body, 0x7c, 2.0); + write_f32(&mut body, 0x80, 1.0); + + let sample = sample_op3_type2_body(&body, 0.5, 0.25).unwrap(); + assert_eq!(sample.position, [4.0, 4.0, 3.0]); + assert_eq!(sample.axis, [3.0, 4.0, 5.0]); + assert_eq!(sample.scale, [2.0, 1.5, 1.25]); + assert_eq!(sample.opacity, 0.5); + assert_eq!(sample.material_phase, 0.25); + } + + #[test] + fn type1_basis_uses_raw_exact_dot_gate_and_native_cross_order() { + // The native comparison is against the raw dot product, so this + // oblique pair is rejected even though the vectors are not parallel. + assert_eq!( + native_op3_type1_basis([1.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0; 3]), + None + ); + assert_eq!( + native_op3_type1_basis([1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0; 3]), + None + ); + assert_eq!( + native_op3_type1_basis([1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0; 3]), + None + ); + + // A non-unit view gives a raw dot of 2 and proceeds to the native + // cross-product frame instead of being normalized before the gate. + let frame = + native_op3_type1_basis([2.0, 1.0, 0.0], [1.0, 0.0, 0.0], [3.0, 4.0, 5.0]).unwrap(); + assert_eq!(frame[0], [0.0, 0.0, 1.0, 3.0]); + assert_eq!(frame[1], [0.0, 1.0, 0.0, 4.0]); + assert_eq!(frame[2], [-1.0, 0.0, 0.0, 5.0]); + + // The axis is not normalized: W and the axis column keep its scale. + let axis_scaled = + native_op3_type1_basis([2.0, 1.0, 0.0], [0.0, 0.0, 2.0], [0.0; 3]).unwrap(); + let inv_sqrt_5 = 1.0 / 5.0_f32.sqrt(); + assert_eq!(axis_scaled[0][0], inv_sqrt_5); + assert_eq!(axis_scaled[1][0], -2.0 * inv_sqrt_5); + assert_eq!(axis_scaled[0][1], 4.0 * inv_sqrt_5); + assert_eq!(axis_scaled[1][1], 2.0 * inv_sqrt_5); + assert_eq!(axis_scaled[2][2], 2.0); + + assert_eq!( + native_op3_type1_basis([f32::NAN, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0; 3]), + None + ); + } + + #[test] + fn type1_command_samples_authored_tracks_and_camera_basis() { + let mut body = vec![0_u8; 196]; + write_u32(&mut body, 0, TYPE1_BODY_DISCRIMINATOR); + write_f32(&mut body, TYPE2_PHASE_START_OFFSET, -1.0); + write_f32(&mut body, TYPE2_PHASE_END_OFFSET, 2.0); + write_f32(&mut body, TYPE2_PHASE_EXPONENT_OFFSET, 1.0); + write_f32(&mut body, TYPE2_OPACITY_START_OFFSET, 1.0); + write_f32(&mut body, TYPE2_OPACITY_END_OFFSET, 0.0); + write_f32(&mut body, TYPE2_OPACITY_EXPONENT_OFFSET, 1.0); + write_f32(&mut body, TYPE2_ACTIVE_START_OFFSET, 0.0); + write_f32(&mut body, TYPE2_ACTIVE_END_OFFSET, 1.0); + write_vec3(&mut body, TYPE2_POSITION_START_OFFSET, [0.0, 0.0, 0.0]); + write_vec3(&mut body, TYPE2_POSITION_END_OFFSET, [2.0, 0.0, 0.0]); + write_vec3(&mut body, TYPE2_POSITION_EXPONENT_OFFSET, [1.0; 3]); + write_vec3(&mut body, TYPE2_AXIS_START_OFFSET, [0.0, 0.0, 1.0]); + write_vec3(&mut body, TYPE2_AXIS_END_OFFSET, [0.0, 0.0, 1.0]); + write_vec3(&mut body, TYPE2_SCALE_START_OFFSET, [1.0; 3]); + write_vec3(&mut body, TYPE2_SCALE_END_OFFSET, [1.0; 3]); + write_vec3(&mut body, 0x78, [1.0; 3]); + body[TYPE2_RESOURCE_OFFSET..TYPE2_RESOURCE_OFFSET + 12].copy_from_slice(b"material.lib"); + body[TYPE2_RESOURCE_OFFSET + 32..TYPE2_RESOURCE_OFFSET + 37].copy_from_slice(b"b_p08"); + + let command = FxCommand { + word: 3, + opcode: FxOpcode::Op3, + native_flag: false, + raw_body: Arc::from(body.into_boxed_slice()), + resource_refs: Vec::new(), + }; + let identity = [ + [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], + ]; + let frame = + evaluate_op3_type1_command(&command, 4, &identity, 0.25, 0.5, [2.0, 1.0, 3.0]).unwrap(); + assert_eq!(frame.command_index, 4); + assert_eq!( + frame.material, + EffectResource { + archive: "material.lib".to_owned(), + name: "b_p08".to_owned(), + } + ); + assert!(frame.active); + assert_eq!(frame.normalized_time, 0.5); + assert_eq!(frame.color, [1.0, 1.0, 1.0, 0.5]); + assert_eq!(frame.material_phase, 0.5); + assert!(evaluate_op3_type1_command( + &command, + 4, + &identity, + 0.25, + 0.5, + [f32::NAN, 1.0, 3.0], + ) + .is_none()); + } + + #[test] + fn rejects_truncated_bodies_and_non_finite_tracks_or_inputs() { + let identity = [ + [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], + ]; + let truncated = FxCommand { + word: 3, + opcode: FxOpcode::Op3, + native_flag: false, + raw_body: Arc::from( + vec![0_u8; TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES - 1].into_boxed_slice(), + ), + resource_refs: Vec::new(), + }; + assert!( + evaluate_op3_type0_command(&truncated, 0, &identity, 0.0, 0.0, [0.0; 3],).is_none() + ); + + let mut type0_body = vec![0_u8; TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES]; + write_u32(&mut type0_body, 0, TYPE0_BODY_DISCRIMINATOR); + write_f32( + &mut type0_body, + TYPE2_POSITION_EXPONENT_OFFSET, + f32::INFINITY, + ); + assert!(sample_op3_type0_body(&type0_body, 0.5, 0.25).is_none()); + + let mut type2_body = vec![0_u8; TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES]; + write_u32(&mut type2_body, 0, TYPE2_BODY_DISCRIMINATOR); + let non_finite_elapsed = FxCommand { + word: 3, + opcode: FxOpcode::Op3, + native_flag: false, + raw_body: Arc::from(type2_body.into_boxed_slice()), + resource_refs: Vec::new(), + }; + assert!(evaluate_op3_type2_command( + &non_finite_elapsed, + 0, + &identity, + f32::INFINITY, + 0.5, + 0, + ) + .is_none()); + } + + fn write_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn write_f32(bytes: &mut [u8], offset: usize, value: f32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn write_vec3(bytes: &mut [u8], offset: usize, value: [f32; 3]) { + for (axis, component) in value.into_iter().enumerate() { + write_f32(bytes, offset + axis * 4, component); + } + } +} diff --git a/crates/fparkan-fx/src/fx_visual.rs b/crates/fparkan-fx/src/fx_visual.rs new file mode 100644 index 0000000..0c09d4a --- /dev/null +++ b/crates/fparkan-fx/src/fx_visual.rs @@ -0,0 +1,1428 @@ +//! Shared, native-shaped output for FX commands that render one four-vertex +//! effect primitive. +//! +//! This module keeps the small affine and frame contracts separate from the +//! environment-specific lightning emitter. Command evaluators only belong +//! here once their native inputs and output fields are established. + +use crate::environment::EffectResource; +use crate::{FxCommand, FxOpcode}; + +/// Canonical native four-vertex topology in helper-local coordinates. +/// +/// The consumer's triangle list is `[0, 1, 2, 2, 3, 0]`. +const EFFECT_QUAD_CORNERS: [[f32; 3]; 4] = [ + [-0.5, 0.5, 0.0], + [-0.5, -0.5, 0.0], + [0.5, -0.5, 0.0], + [0.5, 0.5, 0.0], +]; + +/// Native full-texture quad coordinates, with the 0.99 edge inset. +pub(crate) const EFFECT_QUAD_UV: [[f32; 2]; 4] = + [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]]; + +/// One evaluated native-style FX quad. +#[derive(Clone, Debug, PartialEq)] +pub struct EffectQuadFrame { + /// Source command index in the FXID. + pub command_index: usize, + /// Source command opcode. + pub opcode: FxOpcode, + /// MAT0 resource selected by the command. + pub material: EffectResource, + /// World-space vertices in native corner order. + pub vertices: [[f32; 3]; 4], + /// Native texture coordinates in matching corner order. + pub uv: [[f32; 2]; 4], + /// Per-vertex RGBA value supplied by the native command evaluator. + /// + /// The alpha is the native FX opacity value. Native render assignment + /// replaces MAT0 phase alpha with this value rather than multiplying it. + pub color: [f32; 4], + /// Wrapped command phase supplied to the material's MAT0 animation. + pub material_phase: f32, + /// Effect age in seconds. + pub elapsed_seconds: f32, + /// Normalized command time over its native active interval. + pub normalized_time: f32, + /// Whether the native command is active at `elapsed_seconds`. + pub active: bool, + /// Category-independent native draw-flag base selected by Op9 body C4. + /// + /// The native renderer combines this base with render-context category + /// bits and a trailing command flag. Those inputs are not part of this + /// geometry evaluator's interface yet, so this stores only the proven + /// selector mapping (C4 0/1/2 -> 3/5/6). + pub native_flag_base: Option<u32>, +} + +/// One evaluated indexed mesh emitted by a native FX command. +/// +/// Position and UV arrays use the native element order; `indices` expands +/// those elements into triangles for renderers that consume indexed lists. +#[derive(Clone, Debug, PartialEq)] +pub struct EffectMeshFrame { + /// Source command index in the FXID. + pub command_index: usize, + /// Source command opcode. + pub opcode: FxOpcode, + /// MAT0 resource selected by the command. + pub material: EffectResource, + /// World-space mesh positions in native element order. + pub vertices: Vec<[f32; 3]>, + /// Texture coordinates paired with `vertices`. + pub uv: Vec<[f32; 2]>, + /// Triangle-list indices into `vertices` and `uv`. + pub indices: Vec<u16>, + /// Per-vertex RGBA value supplied by the native command evaluator. + pub color: [f32; 4], + /// Wrapped command phase supplied to the material's MAT0 animation. + pub material_phase: f32, + /// Effect age in seconds. + pub elapsed_seconds: f32, + /// Normalized command time over its native active interval. + pub normalized_time: f32, + /// Whether the native command is active at `elapsed_seconds`. + pub active: bool, + /// Category-independent selector base from Op9 body+C4. + pub native_flag_base: u32, + /// Native category selected by the top two bits of the renderer flags. + pub native_category: u8, +} + +const F_GENER_BALL_RESOURCE_OFFSET: usize = 0x84; +const F_GENER_BALL_RESOURCE_BYTES: usize = 64; + +struct FGenerBallSample { + opcode: FxOpcode, + material: EffectResource, + matrix: [[f32; 4]; 4], + color: [f32; 4], + material_phase: f32, + elapsed_seconds: f32, + normalized_time: f32, + active: bool, + native_flag_base: Option<u32>, +} + +/// Evaluates a supported opcode-4 `f_gener_ball` billboard quad. +/// +/// `world_from_effect` is the row-major native parent matrix after owner +/// transforms and the FX header scale have been applied. `effect_phase` is the +/// normalized overall effect age; `elapsed_seconds` is command age in seconds. +/// `camera_world` is the render-context query point consumed by the native +/// opcode-4 path. Opcode 9 emits generated hemisphere geometry and must use +/// [`evaluate_f_gener_ball_mesh`] instead. +/// +/// Opcode 4 body mode 0 is supported when its distance denominator at +/// body+0xC4 is positive. Other mode/body combinations return `None` until +/// their native branches are verified. +#[must_use] +pub fn evaluate_f_gener_ball( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + effect_phase: f32, + elapsed_seconds: f32, + camera_world: [f32; 3], +) -> Option<EffectQuadFrame> { + if command.opcode != FxOpcode::Op4 { + return None; + } + let sample = evaluate_f_gener_ball_sample( + command, + world_from_effect, + effect_phase, + elapsed_seconds, + camera_world, + )?; + let vertices = transform_native_effect_quad(&sample.matrix)?; + + Some(EffectQuadFrame { + command_index, + opcode: sample.opcode, + material: sample.material, + vertices, + uv: EFFECT_QUAD_UV, + color: sample.color, + elapsed_seconds: sample.elapsed_seconds, + normalized_time: sample.normalized_time, + material_phase: sample.material_phase, + active: sample.active, + native_flag_base: sample.native_flag_base, + }) +} + +/// Evaluates an Op9 `f_gener_ball` command as the native generated hemisphere. +/// +/// `native_category` is `(renderer_flags >> 30)`, the native category index +/// consumed by Terrain's geometry selector. The three C4 selectors map to +/// generated-mesh modes 0, 1, and 2. Body+0xC8 selects the native UV path: +/// zero uses per-vertex projected XY coordinates; nonzero uses Terrain's +/// static quad/triangle UV layouts. Op4 remains a billboard quad and is not +/// accepted here. +#[must_use] +pub fn evaluate_f_gener_ball_mesh( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + effect_phase: f32, + elapsed_seconds: f32, + camera_world: [f32; 3], + native_category: u8, +) -> Option<EffectMeshFrame> { + if command.opcode != FxOpcode::Op9 || native_category > 3 { + return None; + } + let sample = evaluate_f_gener_ball_sample( + command, + world_from_effect, + effect_phase, + elapsed_seconds, + camera_world, + )?; + let native_flag_base = sample.native_flag_base?; + let static_uv = read_u32(&command.raw_body, 0xC8)? != 0; + let (vertices, uv, indices) = + generated_op9_hemisphere(native_flag_base, native_category, static_uv)?; + let vertices = vertices + .into_iter() + .map(|vertex| transform_native_effect_point(&sample.matrix, vertex)) + .collect::<Option<Vec<_>>>()?; + + Some(EffectMeshFrame { + command_index, + opcode: sample.opcode, + material: sample.material, + vertices, + uv, + indices, + color: sample.color, + material_phase: sample.material_phase, + elapsed_seconds: sample.elapsed_seconds, + normalized_time: sample.normalized_time, + active: sample.active, + native_flag_base, + native_category, + }) +} + +fn evaluate_f_gener_ball_sample( + command: &FxCommand, + world_from_effect: &[[f32; 4]; 4], + effect_phase: f32, + elapsed_seconds: f32, + camera_world: [f32; 3], +) -> Option<FGenerBallSample> { + if !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !effect_phase.is_finite() + || !elapsed_seconds.is_finite() + { + return None; + } + if !matches!(command.opcode, FxOpcode::Op9 | FxOpcode::Op4) { + return None; + } + + let body = &command.raw_body; + let mode = read_u32(body, 0x00)?; + let (billboard, distance_denominator, native_flag_base) = match command.opcode { + FxOpcode::Op9 if mode == 2 => { + let selector = read_u32(body, 0xC4)?; + let flag_base = match selector { + 0 => 3, + 1 => 5, + 2 => 6, + _ => return None, + }; + (false, None, Some(flag_base)) + } + FxOpcode::Op4 if mode == 0 => { + let denominator = read_f32(body, 0xC4)?; + if !denominator.is_finite() || denominator <= 0.0 { + return None; + } + (true, Some(denominator), None) + } + _ => return None, + }; + + let resource = read_effect_resource(body)?; + let start = read_f32(body, 0x1C)?; + let end = read_f32(body, 0x20)?; + let phase_start_raw = read_f32(body, 0x04)?; + let phase_end = read_f32(body, 0x08)?; + let phase_exponent = read_f32(body, 0x0C)?; + let opacity_start = read_f32(body, 0x10)?; + let opacity_end = read_f32(body, 0x14)?; + let opacity_exponent = read_f32(body, 0x18)?; + if ![ + start, + end, + phase_start_raw, + phase_end, + phase_exponent, + opacity_start, + opacity_end, + opacity_exponent, + ] + .iter() + .all(|value| value.is_finite()) + || start > end + { + return None; + } + + let interval_time = command_interval_phase(effect_phase, start, end); + let animation_time = if let Some(denominator) = distance_denominator { + if !camera_world.iter().all(|value| value.is_finite()) { + return None; + } + let camera_local = native_effect_local_point(world_from_effect, camera_world)?; + let distance = vector_length(camera_local)?; + (interval_time * distance / denominator).min(1.0) + } else { + interval_time + }; + if !animation_time.is_finite() { + return None; + } + + let position_start = read_vec3(body, 0x24)?; + let position_end = read_vec3(body, 0x30)?; + let position_exponents = read_vec3(body, 0x3C)?; + let axis_start = read_vec3(body, 0x48)?; + let axis_end = read_vec3(body, 0x54)?; + let scale_start = read_vec3(body, 0x60)?; + let scale_end = read_vec3(body, 0x6C)?; + // Native pushes these exponent fields in reverse stack order; their + // resulting XYZ pairing is body+0x80, +0x7C, +0x78. + let scale_exponents = [ + read_f32(body, 0x80)?, + read_f32(body, 0x7C)?, + read_f32(body, 0x78)?, + ]; + + let position = curve_vector( + position_start, + position_end, + position_exponents, + animation_time, + )?; + let axis = lerp_vector(axis_start, axis_end, animation_time); + let scale = curve_vector(scale_start, scale_end, scale_exponents, animation_time)?; + let opacity_curve_time = animation_time.powf(opacity_exponent); + let opacity = + (opacity_start + (opacity_end - opacity_start) * opacity_curve_time).clamp(0.0, 1.0); + + let material_timebase = if phase_start_raw < 0.0 { + elapsed_seconds + } else { + interval_time + }; + let phase_start = phase_start_raw.max(0.0); + let phase = phase_start + (phase_end - phase_start) * material_timebase.powf(phase_exponent); + let material_phase = phase.rem_euclid(1.0); + if !opacity.is_finite() || !material_phase.is_finite() { + return None; + } + + let local_basis = if billboard { + let camera_local = native_effect_local_point(world_from_effect, camera_world)?; + let view = subtract_vector(camera_local, position); + native_camera_facing_basis(view, position)? + } else { + native_d110_basis(axis, position)? + }; + let matrix = compose_native_effect_matrix(world_from_effect, &local_basis, position, scale)?; + let active = effect_phase >= start && effect_phase <= end; + + Some(FGenerBallSample { + opcode: command.opcode, + material: resource, + matrix, + color: [1.0, 1.0, 1.0, opacity], + elapsed_seconds, + normalized_time: animation_time, + material_phase, + active, + native_flag_base, + }) +} + +// Terrain::18520's native mode/category tables, indexed by +// `mode * 4 + (renderer_flags >> 30)`. These values are assigned from the +// Terrain globals at initialization and drive both 18D40 draw loops. +const OP9_SECTOR_COUNTS: [[usize; 4]; 3] = [[8, 6, 5, 4], [16, 12, 10, 8], [24, 20, 16, 10]]; +const OP9_RING_COUNTS: [[usize; 4]; 3] = [[3, 3, 2, 1], [6, 5, 4, 3], [9, 8, 6, 4]]; + +fn generated_op9_hemisphere( + native_flag_base: u32, + native_category: u8, + static_uv: bool, +) -> Option<(Vec<[f32; 3]>, Vec<[f32; 2]>, Vec<u16>)> { + let mode = match native_flag_base { + 3 => 0, + 5 => 1, + 6 => 2, + _ => return None, + }; + let category = usize::from(native_category); + if category >= 4 { + return None; + } + + let sectors = OP9_SECTOR_COUNTS[mode][category]; + let rings = OP9_RING_COUNTS[mode][category]; + let quad_count = sectors.checked_mul(rings.checked_sub(1)?)?; + let vertex_count = quad_count + .checked_mul(4)? + .checked_add(sectors.checked_mul(3)?)?; + let index_count = quad_count + .checked_mul(6)? + .checked_add(sectors.checked_mul(3)?)?; + let mut vertices = Vec::with_capacity(vertex_count); + let mut uv = Vec::with_capacity(vertex_count); + let mut indices = Vec::with_capacity(index_count); + let latitude_step = std::f32::consts::FRAC_PI_2 / rings as f32; + + // 18310 stores quads sector-major, then ring-major. Each cell's order is + // top/current-azimuth, top/next-azimuth, bottom/next, bottom/current. + for sector in 0..sectors { + let phi0 = std::f32::consts::TAU * sector as f32 / sectors as f32; + let phi1 = std::f32::consts::TAU * (sector + 1) as f32 / sectors as f32; + for ring in 0..rings.saturating_sub(1) { + let theta0 = latitude_step * (ring + 1) as f32; + let theta1 = latitude_step * (ring + 2) as f32; + let element_start = u16::try_from(vertices.len()).ok()?; + let element = [ + hemisphere_point(theta0, phi0), + hemisphere_point(theta0, phi1), + hemisphere_point(theta1, phi1), + hemisphere_point(theta1, phi0), + ]; + for (corner, point) in element.into_iter().enumerate() { + uv.push(if static_uv { + EFFECT_QUAD_UV[corner] + } else { + [(point[0] + 1.0) * 0.5, (point[1] + 1.0) * 0.5] + }); + vertices.push(point); + } + indices.extend_from_slice(&[ + element_start, + element_start + 1, + element_start + 2, + element_start + 2, + element_start + 3, + element_start, + ]); + } + } + + // 18190 appends the cap fan after all quads, ordered pole, next sector, + // current sector. The UVs are the same native XY projection as 18310. + for sector in 0..sectors { + let phi0 = std::f32::consts::TAU * sector as f32 / sectors as f32; + let phi1 = std::f32::consts::TAU * (sector + 1) as f32 / sectors as f32; + let theta = latitude_step; + let element_start = u16::try_from(vertices.len()).ok()?; + let element = [ + [0.0, 0.0, 1.0], + hemisphere_point(theta, phi1), + hemisphere_point(theta, phi0), + ]; + let static_triangle_uv = [[0.5, 0.99], [0.0, 0.0], [0.99, 0.0]]; + for (corner, point) in element.into_iter().enumerate() { + uv.push(if static_uv { + static_triangle_uv[corner] + } else { + [(point[0] + 1.0) * 0.5, (point[1] + 1.0) * 0.5] + }); + vertices.push(point); + } + indices.extend_from_slice(&[element_start, element_start + 1, element_start + 2]); + } + + (vertices.len() == vertex_count && indices.len() == index_count) + .then_some((vertices, uv, indices)) +} + +fn hemisphere_point(theta: f32, phi: f32) -> [f32; 3] { + let radial = theta.sin(); + [radial * phi.sin(), radial * phi.cos(), theta.cos()] +} + +pub(crate) fn native_camera_facing_basis( + view: [f32; 3], + position: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + if !view + .iter() + .chain(position.iter()) + .all(|value| value.is_finite()) + { + return None; + } + let length = vector_length(view)?; + if length == 0.0 { + // The native 93F4 branch clears its active byte and returns false when + // camera and animated position coincide. + return None; + } + let view = [view[0] / length, view[1] / length, view[2] / length]; + let mut u = if view[0] == 0.0 { + [1.0, 0.0, 0.0] + } else if view[1] == 0.0 { + [0.0, 1.0, 0.0] + } else { + [-view[1], view[0], 0.0] + }; + if view[2] != 0.0 && view[0] != 0.0 && view[1] != 0.0 { + let u_length = vector_length(u)?; + if u_length == 0.0 { + return None; + } + for component in &mut u { + *component /= u_length; + } + } + let v = cross(view, u); + Some([ + [u[0], v[0], view[0], position[0]], + [u[1], v[1], view[1], position[1]], + [u[2], v[2], view[2], position[2]], + [0.0, 0.0, 0.0, 1.0], + ]) +} + +fn multiply_affine(left: &[[f32; 4]; 4], right: &[[f32; 4]; 4]) -> Option<[[f32; 4]; 4]> { + let result = std::array::from_fn(|row| { + std::array::from_fn(|column| { + (0..4) + .map(|inner| left[row][inner] * right[inner][column]) + .sum::<f32>() + }) + }); + result + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(result) +} + +fn read_effect_resource(body: &[u8]) -> Option<EffectResource> { + let bytes = body.get( + F_GENER_BALL_RESOURCE_OFFSET..F_GENER_BALL_RESOURCE_OFFSET + F_GENER_BALL_RESOURCE_BYTES, + )?; + let archive = bounded_name(&bytes[..32])?; + let name = bounded_name(&bytes[32..])?; + (!archive.is_empty() && !name.is_empty()).then_some(EffectResource { archive, name }) +} + +fn bounded_name(bytes: &[u8]) -> Option<String> { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + let value = std::str::from_utf8(&bytes[..end]).ok()?.trim(); + (!value.is_empty()).then(|| value.to_owned()) +} + +fn read_u32(body: &[u8], offset: usize) -> Option<u32> { + Some(u32::from_le_bytes( + body.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_f32(body: &[u8], offset: usize) -> Option<f32> { + Some(f32::from_le_bytes( + body.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_vec3(body: &[u8], offset: usize) -> Option<[f32; 3]> { + Some([ + read_f32(body, offset)?, + read_f32(body, offset + 4)?, + read_f32(body, offset + 8)?, + ]) +} + +fn command_interval_phase(phase: f32, start: f32, end: f32) -> f32 { + if phase < start { + 0.0 + } else if end == start { + 1.0 + } else if phase >= end { + 1.0 + } else { + ((phase - start) / (end - start)).clamp(0.0, 1.0) + } +} + +/// Builds the native D110 Type2 local basis in row-major matrix form. +/// +/// The X/Y degeneracies use native constant fallbacks. For a general axis U +/// is normalized only when its Z component is nonzero; V is `axis × U`, and +/// the axis vector itself remains unnormalized. +#[must_use] +pub(crate) fn native_d110_basis(axis: [f32; 3], position: [f32; 3]) -> Option<[[f32; 4]; 4]> { + if !axis + .iter() + .chain(position.iter()) + .all(|value| value.is_finite()) + { + return None; + } + + let mut u = if axis[0] == 0.0 { + [1.0, 0.0, 0.0] + } else if axis[1] == 0.0 { + [0.0, 1.0, 0.0] + } else { + [-axis[1], axis[0], 0.0] + }; + if axis[2] != 0.0 && axis[0] != 0.0 && axis[1] != 0.0 { + let length = vector_length(u)?; + if length == 0.0 { + return None; + } + for value in &mut u { + *value /= length; + } + } + + let v = cross(axis, u); + let result = [ + [u[0], v[0], axis[0], position[0]], + [u[1], v[1], axis[1], position[1]], + [u[2], v[2], axis[2], position[2]], + [0.0, 0.0, 0.0, 1.0], + ]; + result + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(result) +} + +fn curve_vector(start: [f32; 3], end: [f32; 3], exponent: [f32; 3], time: f32) -> Option<[f32; 3]> { + let result = std::array::from_fn(|axis| { + start[axis] + (end[axis] - start[axis]) * time.powf(exponent[axis]) + }); + result + .iter() + .all(|value| value.is_finite()) + .then_some(result) +} + +fn lerp_vector(start: [f32; 3], end: [f32; 3], time: f32) -> [f32; 3] { + std::array::from_fn(|axis| start[axis] + (end[axis] - start[axis]) * time) +} + +fn vector_length(vector: [f32; 3]) -> Option<f32> { + let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt(); + length.is_finite().then_some(length) +} + +fn subtract_vector(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] +} + +/// Applies the native affine point transform used by the quad helper. +/// +/// The matrix is row-major. Each output component is the dot product of one +/// of the first three rows with `[x, y, z, 1]`. +#[must_use] +pub(crate) fn transform_native_effect_point( + matrix: &[[f32; 4]; 4], + point: [f32; 3], +) -> Option<[f32; 3]> { + if !matrix.iter().flatten().all(|value| value.is_finite()) + || !point.iter().all(|value| value.is_finite()) + { + return None; + } + let result = std::array::from_fn(|row| { + matrix[row][0] * point[0] + + matrix[row][1] * point[1] + + matrix[row][2] * point[2] + + matrix[row][3] + }); + result + .iter() + .all(|value| value.is_finite()) + .then_some(result) +} + +/// Transforms the native canonical quad through the final helper matrix. +#[must_use] +pub(crate) fn transform_native_effect_quad(matrix: &[[f32; 4]; 4]) -> Option<[[f32; 3]; 4]> { + let vertices = EFFECT_QUAD_CORNERS.map(|corner| transform_native_effect_point(matrix, corner)); + if vertices.iter().any(Option::is_none) { + return None; + } + Some(vertices.map(Option::unwrap)) +} + +/// Applies the native `g_FastProc + 0x68` point operation. +/// +/// This is the native component-wise projection: subtract matrix translation, +/// dot the delta with each basis column, and divide by that column's squared +/// length. It is the `g_FastProc + 0x68` contract used by effect callers. +#[must_use] +pub(crate) fn native_effect_local_point( + matrix: &[[f32; 4]; 4], + point: [f32; 3], +) -> Option<[f32; 3]> { + if !matrix.iter().flatten().all(|value| value.is_finite()) + || !point.iter().all(|value| value.is_finite()) + { + return None; + } + let delta = [ + point[0] - matrix[0][3], + point[1] - matrix[1][3], + point[2] - matrix[2][3], + ]; + let mut result = [0.0; 3]; + for axis in 0..3 { + let basis = [matrix[0][axis], matrix[1][axis], matrix[2][axis]]; + let squared_length = basis + .iter() + .map(|component| component * component) + .sum::<f32>(); + if !squared_length.is_finite() || squared_length == 0.0 { + return None; + } + result[axis] = + (basis[0] * delta[0] + basis[1] * delta[1] + basis[2] * delta[2]) / squared_length; + } + result + .iter() + .all(|value| value.is_finite()) + .then_some(result) +} + +/// Composes a native FX local basis and translation with the effect parent. +/// +/// This matches the shared `93D0` finalization: scale the parent basis columns +/// by the animated scale, multiply by the local basis, then replace the +/// translation with the unscaled parent transform of the animated position. +#[must_use] +pub(crate) fn compose_native_effect_matrix( + world_from_effect: &[[f32; 4]; 4], + local_basis: &[[f32; 4]; 4], + position: [f32; 3], + scale: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + if !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !local_basis.iter().flatten().all(|value| value.is_finite()) + || !position + .iter() + .chain(scale.iter()) + .all(|value| value.is_finite()) + { + return None; + } + let mut scaled_parent = *world_from_effect; + for row in 0..3 { + for column in 0..3 { + scaled_parent[row][column] *= scale[column]; + } + } + let mut result = multiply_affine(&scaled_parent, local_basis)?; + let world_position = transform_native_effect_point(world_from_effect, position)?; + for axis in 0..3 { + result[axis][3] = world_position[axis]; + } + result[3] = [0.0, 0.0, 0.0, 1.0]; + result + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn d110_builds_general_axis_basis_and_preserves_native_axis_length() { + let matrix = native_d110_basis([1.0, 2.0, 2.0], [3.0, 4.0, 5.0]).unwrap(); + let inv_sqrt_5 = 1.0 / 5.0_f32.sqrt(); + assert_eq!(matrix[0], [-2.0 * inv_sqrt_5, -2.0 * inv_sqrt_5, 1.0, 3.0]); + assert_eq!(matrix[1], [inv_sqrt_5, -4.0 * inv_sqrt_5, 2.0, 4.0]); + assert_eq!(matrix[2], [0.0, 5.0 / 5.0_f32.sqrt(), 2.0, 5.0]); + } + + #[test] + fn d110_uses_native_x_then_y_degenerate_fallbacks() { + let x_fallback = native_d110_basis([0.0, 1.0, 0.0], [0.0; 3]).unwrap(); + assert_eq!(x_fallback[0][0..3], [1.0, 0.0, 0.0]); + assert_eq!(x_fallback[1][0..3], [0.0, 0.0, 1.0]); + + let y_fallback = native_d110_basis([1.0, 0.0, 0.0], [0.0; 3]).unwrap(); + assert_eq!(y_fallback[0][0..3], [0.0, 0.0, 1.0]); + assert_eq!(y_fallback[1][0..3], [1.0, 0.0, 0.0]); + } + + #[test] + fn native_point_operation_projects_each_translated_basis_column() { + // Deliberately non-symmetric, translated, and non-orthogonal. The + // native helper projects independently onto each column and divides + // by that column's squared length; this is not a full matrix inverse. + let matrix = [ + [2.0, 3.0, 5.0, 7.0], + [11.0, 13.0, 17.0, 19.0], + [23.0, 29.0, 31.0, 37.0], + [0.0, 0.0, 0.0, 1.0], + ]; + assert_eq!( + native_effect_local_point(&matrix, [41.0, 43.0, 47.0]), + Some([562.0 / 654.0, 704.0 / 1019.0, 888.0 / 1275.0]) + ); + + let nonuniform_translated = [ + [2.0, 0.0, 0.0, 1.0], + [0.0, 3.0, 0.0, 2.0], + [0.0, 0.0, 4.0, 3.0], + [0.0, 0.0, 0.0, 1.0], + ]; + assert_eq!( + native_effect_local_point(&nonuniform_translated, [6.0, 8.0, 8.0]), + Some([2.5, 2.0, 1.25]) + ); + + let tiny_basis = [ + [0.0001, 0.0, 0.0, 0.0], + [0.0, 2.0, 0.0, 0.0], + [0.0, 0.0, 3.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + assert_eq!( + native_effect_local_point(&tiny_basis, [0.0002, 2.0, 3.0]), + Some([2.0, 1.0, 1.0]) + ); + } + + #[test] + fn native_quad_uses_canonical_corner_order_and_affine_rows() { + let matrix = [ + [2.0, 3.0, 5.0, 7.0], + [11.0, 13.0, 17.0, 19.0], + [23.0, 29.0, 31.0, 37.0], + [0.0, 0.0, 0.0, 1.0], + ]; + assert_eq!( + transform_native_effect_quad(&matrix), + Some([ + [7.5, 20.0, 40.0], + [4.5, 7.0, 11.0], + [6.5, 18.0, 34.0], + [9.5, 31.0, 63.0], + ]) + ); + assert_eq!( + EFFECT_QUAD_UV, + [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]] + ); + } + + #[test] + fn affine_helpers_reject_non_finite_inputs_and_results() { + let mut matrix = [[0.0; 4]; 4]; + matrix[3][3] = 1.0; + assert_eq!( + transform_native_effect_point(&matrix, [f32::NAN, 0.0, 0.0]), + None + ); + matrix[0][0] = f32::INFINITY; + assert_eq!(transform_native_effect_quad(&matrix), None); + } + + #[test] + fn f_gener_ball_snapshot_emits_generated_op9_meshes_and_an_op4_quad() { + // Provenance: decoded `effects.rlb/f_gener_ball` snapshot in + // target/research-playground/generator-orb/fx-bodies.txt. The source + // archive SHA-256 was + // 3DAF49A8A4DA2FBC181F86822E125E47EB18F431BB5DF10EC53AC2EDC29C0A89. + // Keep only the known command fields here; no licensed archive bytes + // or unneeded Op1 semantics are copied into the test. + let commands = f_gener_ball_snapshot_commands(); + assert_eq!(commands.len(), 4); + assert_eq!( + commands + .iter() + .map(|command| command.opcode) + .collect::<Vec<_>>(), + [FxOpcode::Op9, FxOpcode::Op9, FxOpcode::Op4, FxOpcode::Op1] + ); + + // The decoded FX header carries a uniform 0.75 parent scale. That + // scale belongs to the input matrix, not the command body. + let parent = uniform_scale_matrix(0.75); + let camera = [0.0, 0.0, 500.0 / 0.75]; + let op9_meshes = commands[..2] + .iter() + .enumerate() + .map(|(index, command)| { + evaluate_f_gener_ball_mesh(command, index, &parent, 0.875, 0.3, camera, 0) + .expect("native Op9 mode emits a generated hemisphere") + }) + .collect::<Vec<_>>(); + assert_eq!(op9_meshes.len(), 2); + assert_eq!(op9_meshes[0].command_index, 0); + assert_eq!(op9_meshes[1].command_index, 1); + + for frame in &op9_meshes { + assert_eq!( + frame.material, + EffectResource { + archive: "material.lib".to_owned(), + name: "laser_y_hit".to_owned(), + } + ); + assert_eq!(frame.normalized_time, 0.5); + assert!((frame.color[3] - 0.5).abs() < 1e-6); + assert!((frame.material_phase - 0.3).abs() < 1e-6); + assert!(frame.active); + assert_eq!(frame.native_flag_base, 3); + assert_eq!(frame.native_category, 0); + assert_eq!(frame.vertices.len(), 88); + assert_eq!(frame.uv.len(), 88); + assert_eq!(frame.indices.len(), 120); + } + assert_ne!(op9_meshes[0].vertices, op9_meshes[1].vertices); + assert!(evaluate_f_gener_ball(&commands[0], 0, &parent, 0.875, 0.3, camera).is_none()); + + let frame = evaluate_f_gener_ball(&commands[2], 2, &parent, 0.875, 0.3, camera) + .expect("Op4 remains a billboard quad"); + assert_eq!( + frame.material, + EffectResource { + archive: "material.lib".to_owned(), + name: "glow_y".to_owned(), + } + ); + // FastProc projects through the parent basis and divides by its + // squared length, so the uniform 0.75 parent scale maps the adjusted + // world camera to local distance 500 / (0.75 * 0.75) before Op4 rescales time. + assert!((frame.normalized_time - 8.0 / 9.0).abs() < 1e-6); + assert!((frame.color[3] - 17.0 / 18.0).abs() < 1e-6); + assert!((frame.material_phase - 0.7).abs() < 1e-6); + assert!(frame.active); + assert_vec3_close(frame.vertices[0], [-3.008_333_4, 3.008_333_4, 0.0]); + } + + #[test] + fn f_gener_ball_samples_position_scale_and_opacity_curves() { + let mut command = f_gener_ball_command(FxOpcode::Op9); + let body = Arc::make_mut(&mut command.raw_body); + put_vec3(body, 0x30, [2.0, 4.0, 8.0]); + put_vec3(body, 0x3C, [1.0, 2.0, 3.0]); + put_vec3(body, 0x48, [0.0, 1.0, 0.0]); + put_vec3(body, 0x54, [0.0, 0.0, 1.0]); + put_vec3(body, 0x60, [1.0, 2.0, 3.0]); + put_vec3(body, 0x6C, [3.0, 6.0, 11.0]); + // Native XYZ exponent order is body+0x80, +0x7C, +0x78. + put_vec3(body, 0x78, [3.0, 2.0, 1.0]); + put_f32(body, 0x10, 0.2); + put_f32(body, 0x14, 1.0); + put_f32(body, 0x18, 2.0); + + let identity = identity_matrix(); + let sample = evaluate_f_gener_ball_sample(&command, &identity, 0.875, 0.35, [0.0; 3]) + .expect("curve fields are valid"); + assert_eq!(sample.normalized_time, 0.5); + assert_vec3_close( + transform_native_effect_point(&sample.matrix, [0.0; 3]).unwrap(), + [1.0, 1.0, 1.0], + ); + for (column, expected_length) in [2.0, 2.5, 2.5].into_iter().enumerate() { + let basis = [ + sample.matrix[0][column], + sample.matrix[1][column], + sample.matrix[2][column], + ]; + assert!((vector_length(basis).unwrap() - expected_length).abs() < 1e-6); + } + assert!((sample.color[3] - 0.4).abs() < 1e-6); + } + + #[test] + fn f_gener_ball_op4_uses_local_camera_distance_and_billboard_basis() { + let command = f_gener_ball_snapshot_commands().remove(2); + let identity = identity_matrix(); + let frame = evaluate_f_gener_ball(&command, 2, &identity, 0.875, 0.3, [0.0, 0.0, 500.0]) + .expect("supported Op4 fixture command"); + + assert_eq!(frame.command_index, 2); + assert_eq!( + frame.material, + EffectResource { + archive: "material.lib".to_owned(), + name: "glow_y".to_owned(), + } + ); + assert_eq!(frame.normalized_time, 0.5); + assert!((frame.material_phase - 0.7).abs() < 1e-6); + assert_eq!(frame.color, [1.0, 1.0, 1.0, 0.75]); + assert!(frame.active); + let expected_extent = 4.6 * 0.5; + assert_eq!( + frame.vertices, + [ + [-expected_extent, expected_extent, 0.0], + [-expected_extent, -expected_extent, 0.0], + [expected_extent, -expected_extent, 0.0], + [expected_extent, expected_extent, 0.0], + ] + ); + + let mut far_camera = [0.0, 0.0, 1000.0]; + let capped = evaluate_f_gener_ball(&command, 2, &identity, 0.875, 0.3, far_camera) + .expect("camera distance caps Op4 progress"); + assert_eq!(capped.normalized_time, 1.0); + far_camera[2] = 0.0; + assert!(evaluate_f_gener_ball(&command, 2, &identity, 0.875, 0.3, far_camera).is_none()); + + let nonuniform_parent = [ + [2.0, 0.0, 0.0, 1.0], + [0.0, 3.0, 0.0, 2.0], + [0.0, 0.0, 4.0, 3.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let camera_world = [6.0, 8.0, 8.0]; + let camera_local = native_effect_local_point(&nonuniform_parent, camera_world).unwrap(); + let expected_time = 0.5 * vector_length(camera_local).unwrap() / 500.0; + let transformed = + evaluate_f_gener_ball(&command, 2, &nonuniform_parent, 0.875, 0.3, camera_world) + .expect("Op4 uses the native column-projection camera query"); + assert!((transformed.normalized_time - expected_time).abs() < 1e-6); + } + + #[test] + fn f_gener_ball_phase_uses_interval_age_before_op4_camera_rescaling() { + let mut command = f_gener_ball_command(FxOpcode::Op4); + let body = Arc::make_mut(&mut command.raw_body); + put_f32(body, 0x04, 0.5); + let identity = identity_matrix(); + + // The command interval maps effect phase 0.875 into age 0.5. Op4's + // 250/500 render-distance factor reduces track time to 0.25, while + // 105F0/108F0 has already computed MAT0 phase from the unscaled 0.5. + let frame = evaluate_f_gener_ball(&command, 2, &identity, 0.875, 0.3, [0.0, 0.0, 250.0]) + .expect("valid Op4 command"); + assert_eq!(frame.normalized_time, 0.25); + assert_eq!(frame.material_phase, 0.75); + } + + #[test] + fn f_gener_ball_rejects_unverified_modes_and_non_finite_state() { + let mut command = f_gener_ball_command(FxOpcode::Op9); + let identity = identity_matrix(); + Arc::make_mut(&mut command.raw_body)[..4].copy_from_slice(&1u32.to_le_bytes()); + assert!( + evaluate_f_gener_ball_mesh(&command, 0, &identity, 0.875, 0.35, [0.0; 3], 0).is_none() + ); + + let mut invalid_selector = f_gener_ball_command(FxOpcode::Op9); + put_u32(Arc::make_mut(&mut invalid_selector.raw_body), 0xC4, 3); + assert!(evaluate_f_gener_ball_mesh( + &invalid_selector, + 0, + &identity, + 0.875, + 0.35, + [0.0; 3], + 0 + ) + .is_none()); + + let command = f_gener_ball_command(FxOpcode::Op9); + let mut invalid_parent = identity; + invalid_parent[1][2] = f32::NAN; + assert!( + evaluate_f_gener_ball_mesh(&command, 0, &invalid_parent, 0.875, 0.35, [0.0; 3], 0) + .is_none() + ); + } + + #[test] + fn op9_c4_selector_maps_to_native_draw_flag_base() { + let identity = identity_matrix(); + for (selector, expected) in [(0, 3), (1, 5), (2, 6)] { + let mut command = f_gener_ball_command(FxOpcode::Op9); + put_u32(Arc::make_mut(&mut command.raw_body), 0xC4, selector); + let frame = evaluate_f_gener_ball_mesh( + &command, + 0, + &identity, + 0.875, + 0.35, + [0.0, 0.0, 500.0], + 0, + ) + .expect("native Op9 C4 selector 0..=2"); + assert_eq!(frame.native_flag_base, expected); + assert_eq!(frame.native_category, 0); + let (sectors, rings) = match selector { + 0 => (8, 3), + 1 => (16, 6), + 2 => (24, 9), + _ => unreachable!(), + }; + let quads = sectors * (rings - 1); + assert_eq!(frame.vertices.len(), quads * 4 + sectors * 3); + assert_eq!(frame.indices.len(), quads * 6 + sectors * 3); + } + } + + #[test] + fn op9_mesh_uses_native_mode_category_counts_and_projected_uvs() { + let identity = identity_matrix(); + let command = f_gener_ball_command(FxOpcode::Op9); + let expected = [ + [(8_usize, 3_usize), (6, 3), (5, 2), (4, 1)], + [(16, 6), (12, 5), (10, 4), (8, 3)], + [(24, 9), (20, 8), (16, 6), (10, 4)], + ]; + + for (selector, mode) in [(0, 0), (1, 1), (2, 2)] { + let mut command = command.clone(); + put_u32(Arc::make_mut(&mut command.raw_body), 0xC4, selector); + for category in 0..4_u8 { + let mesh = evaluate_f_gener_ball_mesh( + &command, + 0, + &identity, + 0.875, + 0.35, + [0.0, 0.0, 500.0], + category, + ) + .expect("all native mode/category combinations have a mesh"); + let (sectors, rings) = expected[mode][usize::from(category)]; + let quads = sectors * (rings - 1); + assert_eq!(mesh.vertices.len(), quads * 4 + sectors * 3); + assert_eq!(mesh.uv.len(), mesh.vertices.len()); + assert_eq!(mesh.indices.len(), quads * 6 + sectors * 3); + assert!(mesh + .vertices + .iter() + .flatten() + .all(|value| value.is_finite())); + assert!(mesh.uv.iter().flatten().all(|value| value.is_finite())); + assert!(mesh + .indices + .iter() + .all(|index| usize::from(*index) < mesh.vertices.len())); + } + } + + let mut mode0_command = command.clone(); + put_u32(Arc::make_mut(&mut mode0_command.raw_body), 0xC4, 0); + put_vec3( + Arc::make_mut(&mut mode0_command.raw_body), + 0x48, + [0.0, 0.0, 1.0], + ); + put_vec3( + Arc::make_mut(&mut mode0_command.raw_body), + 0x54, + [0.0, 0.0, 1.0], + ); + let mesh = evaluate_f_gener_ball_mesh( + &mode0_command, + 0, + &identity, + 0.875, + 0.35, + [0.0, 0.0, 500.0], + 0, + ) + .expect("last selector remains valid"); + // Native triangle cap starts after all sector-major quads. Sector 0 + // is pole, next azimuth, current azimuth with XY-projected UVs. + let cap_start = (8 * (3 - 1)) * 4; + assert_vec3_close(mesh.vertices[cap_start], [0.0, 0.0, 0.4]); + assert_eq!(mesh.uv[cap_start], [0.5, 0.5]); + let next = mesh.vertices[cap_start + 1]; + assert!((next[0] - 0.10606602).abs() < 1e-6); + assert!((next[1] - 0.14142136).abs() < 1e-6); + assert!((next[2] - 0.34641016).abs() < 1e-6); + let cap_index_start = (8 * (3 - 1)) * 6; + assert_eq!(mesh.indices[cap_index_start], cap_start as u16); + assert_eq!(mesh.indices[cap_index_start + 1], cap_start as u16 + 1); + assert_eq!(mesh.indices[cap_index_start + 2], cap_start as u16 + 2); + + let mut static_uv_command = mode0_command.clone(); + put_u32(Arc::make_mut(&mut static_uv_command.raw_body), 0xC8, 1); + let static_uv_mesh = evaluate_f_gener_ball_mesh( + &static_uv_command, + 0, + &identity, + 0.875, + 0.35, + [0.0, 0.0, 500.0], + 0, + ) + .expect("nonzero native UV flag selects static UV arrays"); + assert_eq!(&static_uv_mesh.uv[..4], &EFFECT_QUAD_UV); + assert_eq!( + &static_uv_mesh.uv[cap_start..cap_start + 3], + &[[0.5, 0.99], [0.0, 0.0], [0.99, 0.0]] + ); + assert!(evaluate_f_gener_ball_mesh( + &command, + 0, + &identity, + 0.875, + 0.35, + [0.0, 0.0, 500.0], + 4, + ) + .is_none()); + } + + #[test] + fn op9_generated_mesh_triangles_keep_native_outward_winding() { + for (mode, flag_base) in [(0, 3), (1, 5), (2, 6)] { + for category in 0..4_u8 { + let (vertices, _, indices) = + generated_op9_hemisphere(flag_base, category, false).unwrap(); + assert!(!vertices.is_empty()); + assert_eq!(indices.len() % 3, 0); + for triangle in indices.chunks_exact(3) { + let a = vertices[usize::from(triangle[0])]; + let b = vertices[usize::from(triangle[1])]; + let c = vertices[usize::from(triangle[2])]; + let normal = cross(subtract_vector(b, a), subtract_vector(c, a)); + let centroid = [ + (a[0] + b[0] + c[0]) / 3.0, + (a[1] + b[1] + c[1]) / 3.0, + (a[2] + b[2] + c[2]) / 3.0, + ]; + let outward = + normal[0] * centroid[0] + normal[1] * centroid[1] + normal[2] * centroid[2]; + assert!( + outward > 0.0, + "mode {mode}, category {category}, triangle {triangle:?}" + ); + } + } + } + } + + #[test] + #[ignore = "requires the original GOG installation"] + fn original_gog_recharge_and_tree_plate_op9_modes_evaluate() { + let root = std::env::var_os("PARKAN_GOG_ROOT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy")); + let archive_bytes: Arc<[u8]> = std::fs::read(root.join("effects.rlb")) + .expect("read effects.rlb") + .into(); + let archive = fparkan_nres::decode(archive_bytes, fparkan_nres::ReadProfile::Compatible) + .expect("decode effects.rlb"); + let identity = identity_matrix(); + + for name in ["f_recharge_r", "tree_plate_58"] { + let entry_id = archive + .find(name) + .unwrap_or_else(|| panic!("missing {name}")); + let document = crate::decode_fxid(Arc::from( + archive + .payload(entry_id) + .expect("FXID payload") + .to_vec() + .into_boxed_slice(), + )) + .unwrap_or_else(|error| panic!("decode {name}: {error}")); + let mut seen = false; + for (index, command) in document.commands().iter().enumerate() { + if command.opcode != FxOpcode::Op9 + || read_u32(&command.raw_body, 0) + .unwrap_or_else(|| panic!("{name} malformed Op9 mode")) + != 2 + { + continue; + } + let selector = read_u32(&command.raw_body, 0xC4) + .unwrap_or_else(|| panic!("{name} Op9 missing C4 selector")); + if !(0..=2).contains(&selector) { + continue; + } + let phase_start = read_f32(&command.raw_body, 0x1C) + .unwrap_or_else(|| panic!("{name} Op9 missing phase start")); + let phase_end = read_f32(&command.raw_body, 0x20) + .unwrap_or_else(|| panic!("{name} Op9 missing phase end")); + let frame = evaluate_f_gener_ball_mesh( + command, + index, + &identity, + (phase_start + phase_end) * 0.5, + 0.0, + [0.0, 0.0, 500.0], + 0, + ) + .unwrap_or_else(|| panic!("{name} Op9 selector {selector} did not evaluate")); + assert_eq!(frame.native_flag_base, [3, 5, 6][selector as usize]); + let (sectors, rings) = match selector { + 0 => (8, 3), + 1 => (16, 6), + 2 => (24, 9), + _ => unreachable!(), + }; + assert_eq!( + frame.vertices.len(), + sectors * (rings - 1) * 4 + sectors * 3 + ); + seen = true; + } + assert!(seen, "{name} has no supported Op9 mode"); + } + } + + fn f_gener_ball_snapshot_commands() -> Vec<FxCommand> { + let mut first_op9 = f_gener_ball_command(FxOpcode::Op9); + let mut second_op9 = f_gener_ball_command(FxOpcode::Op9); + put_vec3( + Arc::make_mut(&mut second_op9.raw_body), + 0x48, + [1.0, 0.0, 0.0], + ); + put_vec3( + Arc::make_mut(&mut second_op9.raw_body), + 0x54, + [1.0, 0.0, 0.0], + ); + // Both checked Op9 commands have opacity 0 -> 1 and opposite X axes. + put_f32(Arc::make_mut(&mut first_op9.raw_body), 0x10, 0.0); + put_f32(Arc::make_mut(&mut second_op9.raw_body), 0x10, 0.0); + + let mut op4 = f_gener_ball_command(FxOpcode::Op4); + put_f32(Arc::make_mut(&mut op4.raw_body), 0x10, 0.5); + + // The checked decoder confirms only that the fourth row is Op1. Its + // raw body is intentionally absent: this evaluator does not claim its + // layout or behavior. + let op1 = FxCommand { + word: 1, + opcode: FxOpcode::Op1, + native_flag: false, + raw_body: Arc::from([]), + resource_refs: Vec::new(), + }; + vec![first_op9, second_op9, op4, op1] + } + + fn f_gener_ball_command(opcode: FxOpcode) -> FxCommand { + let is_op9 = opcode == FxOpcode::Op9; + let mut body = vec![0u8; if is_op9 { 0xCC } else { 0xC8 }]; + let mode = if is_op9 { 2 } else { 0 }; + put_u32(&mut body, 0x00, mode); + put_f32(&mut body, 0x04, -1.0); + put_f32(&mut body, 0x08, if is_op9 { 11.0 } else { 9.0 }); + put_f32(&mut body, 0x0C, 1.0); + put_f32(&mut body, 0x10, 0.0); + put_f32(&mut body, 0x14, 1.0); + put_f32(&mut body, 0x18, 1.0); + put_f32(&mut body, 0x1C, 0.75); + put_f32(&mut body, 0x20, 1.0); + put_vec3(&mut body, 0x24, [0.0; 3]); + put_vec3(&mut body, 0x30, [0.0; 3]); + put_vec3(&mut body, 0x3C, [1.0; 3]); + let axis = if is_op9 { + [-1.0, 0.0, 0.0] + } else { + [1.0, 0.0, 0.0] + }; + put_vec3(&mut body, 0x48, axis); + put_vec3(&mut body, 0x54, axis); + let scale_start = if is_op9 { [0.3, 0.4, 0.4] } else { [0.2; 3] }; + let scale_end = if is_op9 { [0.3, 0.4, 0.4] } else { [9.0; 3] }; + put_vec3(&mut body, 0x60, scale_start); + put_vec3(&mut body, 0x6C, scale_end); + put_vec3(&mut body, 0x78, [1.0; 3]); + put_name(&mut body, 0x84, "material.lib"); + put_name( + &mut body, + 0xA4, + if is_op9 { "laser_y_hit" } else { "glow_y" }, + ); + if is_op9 { + put_u32(&mut body, 0xC4, 0); + } else { + put_f32(&mut body, 0xC4, 500.0); + } + FxCommand { + word: if is_op9 { 9 } else { 4 }, + opcode, + native_flag: false, + raw_body: Arc::from(body), + resource_refs: Vec::new(), + } + } + + fn put_u32(body: &mut [u8], offset: usize, value: u32) { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn put_f32(body: &mut [u8], offset: usize, value: f32) { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn put_vec3(body: &mut [u8], offset: usize, value: [f32; 3]) { + for (index, component) in value.into_iter().enumerate() { + put_f32(body, offset + index * 4, component); + } + } + + fn put_name(body: &mut [u8], offset: usize, value: &str) { + body[offset..offset + value.len()].copy_from_slice(value.as_bytes()); + } + + fn identity_matrix() -> [[f32; 4]; 4] { + [ + [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], + ] + } + + fn uniform_scale_matrix(scale: f32) -> [[f32; 4]; 4] { + [ + [scale, 0.0, 0.0, 0.0], + [0.0, scale, 0.0, 0.0], + [0.0, 0.0, scale, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + } + + fn assert_vec3_close(actual: [f32; 3], expected: [f32; 3]) { + for (actual, expected) in actual.into_iter().zip(expected) { + assert!((actual - expected).abs() < 1e-6, "{actual} != {expected}"); + } + } +} diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index 2ef5b56..4125a68 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -6,10 +6,27 @@ //! reference stub until opcode timing, gates, RNG, and command-body semantics //! are backed by runtime-captured evidence. +pub mod ambient_op3; pub mod atmosphere; pub mod environment; +pub mod fx_visual; +pub mod op7; pub mod shadow; pub mod sky; +pub mod smoke; + +/// Advances the native FX 16-bit-pair random state and returns its new high half. +/// +/// Native helpers store `low' = low * 2 ^ high` and `high' = high >> 1 ^ low'`. +#[must_use] +pub fn advance_native_fx_random_state(state: &mut u32) -> u16 { + let low = *state as u16; + let high = (*state >> 16) as u16; + let next_low = low.wrapping_shl(1) ^ high; + let next_high = (high >> 1) ^ next_low; + *state = u32::from(next_low) | (u32::from(next_high) << 16); + next_high +} use fparkan_binary::{Cursor, DecodeError}; use std::sync::Arc; diff --git a/crates/fparkan-fx/src/op7.rs b/crates/fparkan-fx/src/op7.rs new file mode 100644 index 0000000..ac31f44 --- /dev/null +++ b/crates/fparkan-fx/src/op7.rs @@ -0,0 +1,1443 @@ +//! Native-shaped evaluation for opcode-7 particle pools. +//! +//! Op7 is a fixed pool of independently randomized position and scale tracks. +//! It has a different lifecycle from the scheduled ring used by Op8. + +use crate::environment::EffectResource; +use crate::fx_visual::{ + compose_native_effect_matrix, native_camera_facing_basis, native_effect_local_point, + transform_native_effect_quad, EffectQuadFrame, EFFECT_QUAD_UV, +}; +use crate::{FxCommand, FxOpcode}; +use std::sync::Arc; + +const RESOURCE_OFFSET: usize = 0x8C; +const RESOURCE_BYTES: usize = 64; +const MIN_BODY_SIZE: usize = RESOURCE_OFFSET + RESOURCE_BYTES; +const MAX_PARTICLES: usize = 4096; +const U16_TO_UNIT: f32 = 1.0 / 65_536.0; + +#[derive(Clone, Copy, Debug)] +struct Track { + start: [f32; 3], + end: [f32; 3], + exponent: [f32; 3], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Lfsr16Pair { + state: u32, +} + +impl Lfsr16Pair { + fn from_word(state: u32) -> Self { + Self { state } + } + + fn next_unit(&mut self) -> f32 { + f32::from(crate::advance_native_fx_random_state(&mut self.state)) * U16_TO_UNIT + } + + fn next_signed(&mut self, width: f32) -> f32 { + (self.next_unit() - 0.5) * width + } +} + +#[derive(Clone, Debug)] +struct Particle { + group: usize, + helper_mode: u32, + visibility_cutoff: f32, + position: Track, + scale: Track, +} + +/// Mutable deterministic state for one Op7 command instance. +/// +/// Native Op7 initializes all slots once and then samples their fixed tracks; +/// retain one state per command instance and call [`evaluate_op7_emitter`] on +/// each update. The seed only supplies deterministic stand-ins for the +/// process-global native random streams. +#[derive(Clone, Debug)] +pub struct Op7EmitterState { + initial_particle_rng: Lfsr16Pair, + initial_visibility_rng: Lfsr16Pair, + particle_rng: Lfsr16Pair, + visibility_rng: Lfsr16Pair, + body: Option<Arc<[u8]>>, + visibility_profile: Option<Op7VisibilityProfile>, + particles: Vec<Particle>, + last_clock: Option<f32>, + group_clocks: Vec<f32>, +} + +impl Op7EmitterState { + /// Creates deterministic evaluator state from particle and visibility seeds. + #[must_use] + pub fn new(particle_seed: u32, visibility_seed: u32) -> Self { + let initial_particle_rng = Lfsr16Pair::from_word(particle_seed); + let initial_visibility_rng = Lfsr16Pair::from_word(visibility_seed); + Self { + initial_particle_rng, + initial_visibility_rng, + particle_rng: initial_particle_rng, + visibility_rng: initial_visibility_rng, + body: None, + visibility_profile: None, + particles: Vec::new(), + last_clock: None, + group_clocks: Vec::new(), + } + } + + /// Clears initialized slots and restores the initial random streams. + fn reset(&mut self) { + self.particle_rng = self.initial_particle_rng; + self.visibility_rng = self.initial_visibility_rng; + self.body = None; + self.visibility_profile = None; + self.particles.clear(); + self.last_clock = None; + self.group_clocks.clear(); + } +} + +/// Native Op7's per-slot visibility range from the effect settings tables. +/// +/// `width` and `bias` are the pair selected by the caller's native settings +/// context (`settings_id >> 8`) for helper 0xEC50. The sampled cutoff is +/// `random_unit * width - bias`; this is a detail cutoff, not a time offset. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Op7VisibilityProfile { + /// Width from the native settings table. + pub width: f32, + /// Bias from the native settings table. + pub bias: f32, +} + +/// Per-render context consumed by the native Op7 visibility gate. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Op7RenderContext { + /// Ordinal returned by the native render-context detail selector. + /// The native outer dispatcher bypasses rendering for values >= 8. + pub detail_level: u32, + /// Native settings-table pair used to initialize fixed slot cutoffs. + pub visibility: Op7VisibilityProfile, + /// Native world scalar returned by Terrain.dll's GetWorld vslot +0x10. + /// Effect.dll multiplies Op7 body B88 by this value and by 0.5. + pub world_scalar: f32, +} + +#[derive(Clone, Debug)] +struct Params { + material: EffectResource, + helper_mode: u32, + pool_count: usize, + copies_per_group: usize, + start: f32, + end: f32, + lifetime: f32, + material_phase_factor: f32, + vertical_acceleration: f32, + opacity_start: f32, + opacity_end: f32, + opacity_exponent: f32, + position: Track, + position_width: [f32; 3], + scale: Track, + scale_width: [f32; 3], +} + +impl Params { + fn parse(command: &FxCommand, world_scalar: f32) -> Option<Self> { + let body = &command.raw_body; + if body.len() < MIN_BODY_SIZE { + return None; + } + let start = read_f32(body, 0x10)?; + let end = read_f32(body, 0x14)?; + let lifetime = read_f32(body, 0x18)?; + let material_phase_factor = read_f32(body, 0x1C)?; + let vertical_acceleration = read_f32(body, 0x88)? * world_scalar * 0.5; + let pool_count = read_u32(body, 0x20)? as usize; + let copies_per_group = read_u32(body, 0x24)? as usize; + let opacity_start = read_f32(body, 0x04)?; + let opacity_end = read_f32(body, 0x08)?; + let opacity_exponent = read_f32(body, 0x0C)?; + let position = Track { + start: read_vec3(body, 0x28)?, + end: read_vec3(body, 0x34)?, + exponent: read_vec3(body, 0x4C)?, + }; + let position_width = read_vec3(body, 0x40)?; + let scale = Track { + start: read_vec3(body, 0x58)?, + end: read_vec3(body, 0x64)?, + exponent: read_vec3(body, 0x7C)?, + }; + let scale_width = read_vec3(body, 0x70)?; + let particle_count = pool_count.checked_mul(copies_per_group)?; + let mode_field = read_u32(body, 0x00)?; + let helper_mode = if mode_field == 1 { 3 } else { 0 }; + if start >= end + || !lifetime.is_finite() + || lifetime <= 0.0 + || !material_phase_factor.is_finite() + || material_phase_factor == 0.0 + || !world_scalar.is_finite() + || !vertical_acceleration.is_finite() + || pool_count == 0 + || copies_per_group == 0 + || particle_count > MAX_PARTICLES + || ![start, end, opacity_start, opacity_end, opacity_exponent] + .iter() + .all(|value| value.is_finite()) + || !position + .start + .iter() + .chain(position.end.iter()) + .chain(position.exponent.iter()) + .chain(position_width.iter()) + .chain(scale.start.iter()) + .chain(scale.end.iter()) + .chain(scale.exponent.iter()) + .chain(scale_width.iter()) + .all(|value| value.is_finite()) + { + return None; + } + Some(Self { + material: read_resource(body)?, + helper_mode, + pool_count, + copies_per_group, + start, + end, + lifetime, + material_phase_factor, + vertical_acceleration, + opacity_start, + opacity_end, + opacity_exponent, + position, + position_width, + scale, + scale_width, + }) + } +} + +/// Evaluates an Op7 fixed particle pool at one effect phase. +/// +/// `effect_phase` is the native command clock used by the inclusive body +/// window. `world_from_effect` is the owner transform after the FX header +/// transform, and `camera_world` is the render context point used by the +/// particle billboard. Frames are returned only for slots whose staggered +/// lifetime is currently active. +#[must_use] +pub fn evaluate_op7_emitter( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + camera_world: [f32; 3], + effect_phase: f32, + elapsed_seconds: f32, + render_context: Op7RenderContext, + state: &mut Op7EmitterState, +) -> Option<Vec<EffectQuadFrame>> { + if command.opcode != FxOpcode::Op7 + || !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !camera_world.iter().all(|value| value.is_finite()) + || !effect_phase.is_finite() + || !elapsed_seconds.is_finite() + || !render_context.visibility.width.is_finite() + || !render_context.visibility.bias.is_finite() + || !render_context.world_scalar.is_finite() + || render_context.visibility.width < 0.0 + { + return None; + } + let params = Params::parse(command, render_context.world_scalar)?; + if state + .body + .as_deref() + .is_some_and(|body| body != command.raw_body.as_ref()) + { + state.reset(); + } + if state + .visibility_profile + .is_some_and(|profile| profile != render_context.visibility) + { + // The native base class samples these cutoffs once during object + // initialization. A caller changing the settings pair mid-emitter is + // not the same native state, so reject that frame instead of silently + // rerolling particle and visibility RNGs. + return None; + } + state.body = Some(Arc::clone(&command.raw_body)); + if state.particles.is_empty() { + initialize_particles(¶ms, render_context.visibility, state)?; + state.visibility_profile = Some(render_context.visibility); + } + + let frame_delta = elapsed_seconds - state.last_clock.unwrap_or(0.0); + if !frame_delta.is_finite() { + return None; + } + let in_window = effect_phase >= params.start && effect_phase <= params.end; + state.last_clock = Some(elapsed_seconds); + + if render_context.detail_level >= 8 || !in_window { + return Some(Vec::new()); + } + let window_phase = (effect_phase - params.start) / (params.end - params.start); + let mut frames = Vec::with_capacity(params.particles_len()); + let mut group_contexts = Vec::with_capacity(params.pool_count); + for group in 0..params.pool_count { + let stagger = (1.0 - params.lifetime) * group as f32 / params.pool_count as f32; + let group_age = (window_phase - stagger) / params.lifetime; + if !(0.0..=1.0).contains(&group_age) { + group_contexts.push(None); + continue; + } + let mut owner_matrix = *world_from_effect; + if params.vertical_acceleration != 0.0 { + let group_clock = *state.group_clocks.get(group)?; + let delta_z = group_clock * group_clock * params.vertical_acceleration; + if !delta_z.is_finite() { + return None; + } + // The native update accumulator updates the emitter's bounds + // position, while the renderer applies only this group's clock + // to its copied owner matrix. + owner_matrix[2][3] -= delta_z; + let group_clock = state.group_clocks.get_mut(group)?; + *group_clock += frame_delta; + if !group_clock.is_finite() { + return None; + } + } + let camera_local = native_effect_local_point(&owner_matrix, camera_world)?; + group_contexts.push(Some((owner_matrix, camera_local))); + } + for particle in &state.particles { + // Renderer 01300 converts context+0x60's DWORD ordinal with FILD and + // skips this slot only when the ordinal compares greater than the + // native per-slot float in state+0x2c. + if render_context.detail_level as f32 > particle.visibility_cutoff { + continue; + } + // Native 01000 spaces group starts by (1 - lifetime) / pool_count; + // the second pool dimension shares its group's start phase. + let stagger = (1.0 - params.lifetime) * particle.group as f32 / params.pool_count as f32; + let age = (window_phase - stagger) / params.lifetime; + if !(0.0..=1.0).contains(&age) { + continue; + } + let Some(position) = sample_track(particle.position, age) else { + return None; + }; + let Some(scale) = sample_track(particle.scale, age) else { + return None; + }; + let Some((owner_matrix, camera_local)) = group_contexts.get(particle.group)?.as_ref() + else { + continue; + }; + let camera_local = *camera_local; + let opacity = params.opacity_start + + (params.opacity_end - params.opacity_start) * age.powf(params.opacity_exponent); + if !opacity.is_finite() { + return None; + } + let Some(local_basis) = + native_mode_basis(particle.helper_mode, owner_matrix, camera_local, position) + else { + continue; + }; + let matrix = compose_native_effect_matrix(owner_matrix, &local_basis, position, scale)?; + let vertices = transform_native_effect_quad(&matrix)?; + frames.push(EffectQuadFrame { + command_index, + opcode: FxOpcode::Op7, + material: params.material.clone(), + vertices, + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, opacity.clamp(0.0, 1.0)], + // Native material timing uses the normalized effect-window phase + // minus the group offset. It does not reuse the lifetime-scaled + // particle age used by position, scale, and opacity. + material_phase: native_material_phase( + window_phase - stagger, + params.material_phase_factor, + )?, + elapsed_seconds, + normalized_time: age, + active: true, + native_flag_base: None, + }); + } + Some(frames) +} + +impl Params { + fn particles_len(&self) -> usize { + self.pool_count * self.copies_per_group + } +} + +fn initialize_particles( + params: &Params, + visibility: Op7VisibilityProfile, + state: &mut Op7EmitterState, +) -> Option<()> { + state.particles.reserve(params.particles_len()); + state.group_clocks.resize(params.pool_count, 0.0); + for group in 0..params.pool_count { + for _ in 0..params.copies_per_group { + state.particles.push(Particle { + group, + helper_mode: params.helper_mode, + visibility_cutoff: state.visibility_rng.next_unit() * visibility.width + - visibility.bias, + position: randomize_track( + params.position, + params.position_width, + &mut state.particle_rng, + )?, + scale: randomize_track(params.scale, params.scale_width, &mut state.particle_rng)?, + }); + } + } + Some(()) +} + +fn randomize_track( + mut track: Track, + width: [f32; 3], + particle_rng: &mut Lfsr16Pair, +) -> Option<Track> { + // Native 01720 advances the same process-global state for Z, Y, and X; + // scale randomization continues that stream after position randomization. + track.end[2] += particle_rng.next_unit() * width[2] - width[2] * 0.5; + track.end[1] += particle_rng.next_signed(width[1]); + track.end[0] += particle_rng.next_signed(width[0]); + track + .start + .iter() + .chain(track.end.iter()) + .chain(track.exponent.iter()) + .all(|value| value.is_finite()) + .then_some(track) +} + +fn native_material_phase(window_phase: f32, factor: f32) -> Option<f32> { + let phase = if factor > 1.0 { + (window_phase * factor).fract() + } else { + window_phase / factor + }; + phase + .is_finite() + .then_some(if phase >= 1.0 { 0.99 } else { phase }) +} + +fn sample_track(track: Track, phase: f32) -> Option<[f32; 3]> { + if !phase.is_finite() || !(0.0..=1.0).contains(&phase) { + return None; + } + let mut result = [0.0; 3]; + for axis in 0..3 { + let curve = phase.powf(track.exponent[axis]); + result[axis] = track.start[axis] + (track.end[axis] - track.start[axis]) * curve; + } + result + .iter() + .all(|value| value.is_finite()) + .then_some(result) +} + +fn native_mode_basis( + helper_mode: u32, + world_from_effect: &[[f32; 4]; 4], + camera_local: [f32; 3], + position: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + match helper_mode { + 0 => { + let view = subtract(camera_local, position); + native_camera_facing_basis(view, position) + } + 3 => native_mode_three_basis(world_from_effect, camera_local, position), + _ => None, + } +} + +/// Reproduces Op7's native helper-mode-3 basis selection (Effect.dll 9575, +/// with 038D0 as its degenerate-cross fallback). +fn native_mode_three_basis( + world_from_effect: &[[f32; 4]; 4], + camera_local: [f32; 3], + position: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + // The slot helper's +0x54 point is initialized to zero by 092D0. Native + // FastProc+0x68 projects that world point through the owner matrix before + // using it as the forward-vector origin. + let origin_local = native_effect_local_point(world_from_effect, [0.0; 3])?; + let forward = normalize(subtract(position, origin_local))?; + let to_camera = subtract(camera_local, position); + let dot = dot(forward, to_camera); + if !dot.is_finite() || dot.abs() == 1.0 { + // The x87 comparison at 9667..9683 clears the helper's active byte for + // the exact-equality or unordered path. + return None; + } + + let right = cross(to_camera, forward); + let local_right = match normalize(right) { + Some(right) => right, + None => mode_three_fallback_right(forward)?, + }; + Some(matrix_from_forward_right(forward, local_right, position)) +} + +fn mode_three_fallback_right(forward: [f32; 3]) -> Option<[f32; 3]> { + let right = if forward[0] == 0.0 { + [0.0, 1.0, 0.0] + } else if forward[1] == 0.0 { + [1.0, 0.0, 0.0] + } else { + [-forward[1], forward[0], 0.0] + }; + if forward[2] != 0.0 && forward[0] != 0.0 && forward[1] != 0.0 { + normalize(right) + } else { + Some(right) + } +} + +fn matrix_from_forward_right( + forward: [f32; 3], + right: [f32; 3], + position: [f32; 3], +) -> [[f32; 4]; 4] { + let up = cross(forward, right); + [ + [forward[0], right[0], up[0], position[0]], + [forward[1], right[1], up[1], position[1]], + [forward[2], right[2], up[2], position[2]], + [0.0, 0.0, 0.0, 1.0], + ] +} + +fn subtract(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn dot(left: [f32; 3], right: [f32; 3]) -> f32 { + left[0] * right[0] + left[1] * right[1] + left[2] * right[2] +} + +fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] +} + +fn normalize(vector: [f32; 3]) -> Option<[f32; 3]> { + if !vector.iter().all(|component| component.is_finite()) { + return None; + } + let length = dot(vector, vector).sqrt(); + if !length.is_finite() || length == 0.0 { + return None; + } + Some([vector[0] / length, vector[1] / length, vector[2] / length]) +} + +fn read_resource(body: &[u8]) -> Option<EffectResource> { + let bytes = body.get(RESOURCE_OFFSET..RESOURCE_OFFSET + RESOURCE_BYTES)?; + let archive = read_name(&bytes[..32])?; + let name = read_name(&bytes[32..])?; + (!archive.is_empty() && !name.is_empty()).then_some(EffectResource { archive, name }) +} + +fn read_name(bytes: &[u8]) -> Option<String> { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + Some(std::str::from_utf8(&bytes[..end]).ok()?.trim().to_owned()) +} + +fn read_u32(body: &[u8], offset: usize) -> Option<u32> { + Some(u32::from_le_bytes( + body.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_f32(body: &[u8], offset: usize) -> Option<f32> { + Some(f32::from_le_bytes( + body.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_vec3(body: &[u8], offset: usize) -> Option<[f32; 3]> { + Some([ + read_f32(body, offset)?, + read_f32(body, offset + 4)?, + read_f32(body, offset + 8)?, + ]) +} + +#[cfg(test)] +mod tests { + use super::{ + evaluate_op7_emitter, Lfsr16Pair, Op7EmitterState, Op7RenderContext, Op7VisibilityProfile, + }; + use crate::{decode_fxid, FxCommand, FxOpcode, FXID_KIND}; + use fparkan_nres::{decode, ReadProfile}; + use std::path::PathBuf; + use std::sync::Arc; + + fn command() -> FxCommand { + let mut body = vec![0; 0xCC]; + put_f32(&mut body, 0x04, 1.0); + put_f32(&mut body, 0x08, 0.0); + put_f32(&mut body, 0x0C, 1.0); + put_f32(&mut body, 0x10, 0.05); + put_f32(&mut body, 0x14, 0.5); + put_f32(&mut body, 0x18, 0.5); + put_f32(&mut body, 0x1C, 1.5); + put_u32(&mut body, 0x20, 6); + put_u32(&mut body, 0x24, 1); + put_vec3(&mut body, 0x28, [0.0, 0.0, 0.0]); + put_vec3(&mut body, 0x34, [1.0, 0.0, 6.0]); + put_vec3(&mut body, 0x40, [1.0, 1.0, 2.0]); + put_vec3(&mut body, 0x4C, [1.0; 3]); + put_vec3(&mut body, 0x58, [2.5; 3]); + put_vec3(&mut body, 0x64, [2.0; 3]); + put_vec3(&mut body, 0x70, [1.5; 3]); + put_vec3(&mut body, 0x7C, [1.0; 3]); + body[0x8C..0x98].copy_from_slice(b"material.lib"); + body[0xAC..0xB4].copy_from_slice(b"smoke_yg"); + FxCommand { + word: 0x107, + opcode: FxOpcode::Op7, + native_flag: true, + raw_body: Arc::from(body.into_boxed_slice()), + resource_refs: Vec::new(), + } + } + + fn b88_command(pool_count: u32, lifetime: f32) -> FxCommand { + let mut command = command(); + let mut body = command.raw_body.to_vec(); + put_f32(&mut body, 0x10, 0.0); + put_f32(&mut body, 0x14, 1.0); + put_f32(&mut body, 0x18, lifetime); + put_u32(&mut body, 0x20, pool_count); + put_u32(&mut body, 0x24, 1); + put_vec3(&mut body, 0x28, [0.0; 3]); + put_vec3(&mut body, 0x34, [0.0; 3]); + put_vec3(&mut body, 0x40, [0.0; 3]); + put_vec3(&mut body, 0x58, [1.0; 3]); + put_vec3(&mut body, 0x64, [1.0; 3]); + put_vec3(&mut body, 0x70, [0.0; 3]); + put_f32(&mut body, 0x88, 1.0); + command.raw_body = Arc::from(body.into_boxed_slice()); + command + } + + fn render_context(detail_level: u32) -> Op7RenderContext { + Op7RenderContext { + detail_level, + // Synthetic profile used to exercise the native ordinal gate. + // Production callers pass their resolved runtime settings pair. + visibility: Op7VisibilityProfile { + width: 3.0, + bias: 0.0, + }, + world_scalar: 10.0, + } + } + + fn matrix_center_z(frame: &crate::fx_visual::EffectQuadFrame) -> f32 { + frame.vertices.iter().map(|vertex| vertex[2]).sum::<f32>() / 4.0 + } + + #[test] + fn op7_lfsr_matches_native_two_half_transition() { + let mut rng = Lfsr16Pair { state: 0x1234_5678 }; + assert_eq!(rng.next_unit(), f32::from(0xB7DE_u16) / 65_536.0); + assert_eq!(rng, Lfsr16Pair { state: 0xB7DE_BEC4 }); + assert_eq!(rng.next_unit(), f32::from(0x91B9_u16) / 65_536.0); + assert_eq!(rng, Lfsr16Pair { state: 0x91B9_CA56 }); + } + + fn put_u32(body: &mut [u8], offset: usize, value: u32) { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn put_f32(body: &mut [u8], offset: usize, value: f32) { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn put_vec3(body: &mut [u8], offset: usize, value: [f32; 3]) { + for (index, component) in value.into_iter().enumerate() { + put_f32(body, offset + index * 4, component); + } + } + + fn centroid(frame: &crate::fx_visual::EffectQuadFrame) -> [f32; 3] { + std::array::from_fn(|axis| { + frame + .vertices + .iter() + .map(|vertex| vertex[axis]) + .sum::<f32>() + / 4.0 + }) + } + + #[test] + fn op7_samples_randomized_tracks_and_native_group_stagger() { + let command = command(); + let identity = [ + [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], + ]; + let mut state = Op7EmitterState::new(17, 29); + + assert!( + evaluate_op7_emitter( + &command, + 0, + &identity, + [0.0, 0.0, -20.0], + 0.05, + 0.0, + render_context(0), + &mut state, + ) + .unwrap() + .len() + <= 1 + ); + let frames = evaluate_op7_emitter( + &command, + 0, + &identity, + [0.0, 0.0, -20.0], + 0.25, + 0.5, + render_context(0), + &mut state, + ) + .unwrap(); + assert_eq!(frames.len(), 6); + assert!(frames.iter().all(|frame| frame.opcode == FxOpcode::Op7)); + assert!(frames.iter().all(|frame| frame.material.name == "smoke_yg")); + let first_age = frames[0].normalized_time; + assert!((first_age - 0.888_888_9).abs() < 1e-5); + for (left, right) in frames.iter().zip(frames.iter().skip(1)) { + assert!((left.normalized_time - right.normalized_time - 1.0 / 6.0).abs() < 1e-5); + } + assert!((frames[0].color[3] - (1.0 - first_age)).abs() < 1e-5); + let window_phase = (0.25_f32 - 0.05) / (0.5 - 0.05); + let native_material_phase = (window_phase * 1.5).fract(); + assert!((frames[0].material_phase - native_material_phase).abs() < 1e-5); + // Native material timing uses the window phase (about 0.444), not + // the lifetime-scaled particle age (about 0.889). + assert!((frames[0].material_phase - first_age).abs() > 0.1); + assert!(centroid(&frames[0])[2] > 0.0); + } + + #[test] + fn op7_b88_uses_world_scalar_and_moves_owner_and_camera_basis() { + let command = b88_command(1, 1.0); + let mut unaccelerated = command.clone(); + Arc::make_mut(&mut unaccelerated.raw_body)[0x88..0x8C] + .copy_from_slice(&0.0_f32.to_le_bytes()); + let identity = [ + [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], + ]; + let camera = [10.0, 2.0, 10.0]; + + let mut default_state = Op7EmitterState::new(31, 31); + let default_context = render_context(0); + let default_first = evaluate_op7_emitter( + &command, + 0, + &identity, + camera, + 0.25, + 0.2, + default_context, + &mut default_state, + ) + .expect("native default world scalar"); + assert_eq!(default_first.len(), 1); + assert!(matrix_center_z(&default_first[0]).abs() < 1e-6); + assert!((default_state.group_clocks[0] - 0.2).abs() < 1e-6); + let default_frames = evaluate_op7_emitter( + &command, + 0, + &identity, + camera, + 0.3, + 0.4, + default_context, + &mut default_state, + ) + .expect("second update renders with prior group clock only"); + assert!((matrix_center_z(&default_frames[0]) + 0.2).abs() < 1e-6); + assert!((default_state.group_clocks[0] - 0.4).abs() < 1e-6); + + let mut alternate_state = Op7EmitterState::new(31, 31); + let mut alternate_context = render_context(0); + alternate_context.world_scalar = 4.0; + let alternate_first = evaluate_op7_emitter( + &command, + 0, + &identity, + camera, + 0.25, + 0.2, + alternate_context, + &mut alternate_state, + ) + .expect("alternate world scalar"); + assert!(matrix_center_z(&alternate_first[0]).abs() < 1e-6); + let alternate_frames = evaluate_op7_emitter( + &command, + 0, + &identity, + camera, + 0.3, + 0.4, + alternate_context, + &mut alternate_state, + ) + .expect("alternate scalar renders with prior group clock only"); + assert!((matrix_center_z(&alternate_frames[0]) + 0.08).abs() < 1e-6); + + let mut unaccelerated_state = Op7EmitterState::new(31, 31); + let unaccelerated_first = evaluate_op7_emitter( + &unaccelerated, + 0, + &identity, + camera, + 0.25, + 0.2, + default_context, + &mut unaccelerated_state, + ) + .expect("zero B88 command"); + let unaccelerated_frames = evaluate_op7_emitter( + &unaccelerated, + 0, + &identity, + camera, + 0.3, + 0.4, + default_context, + &mut unaccelerated_state, + ) + .expect("zero B88 command on second update"); + assert!(matrix_center_z(&unaccelerated_first[0]).abs() < 1e-6); + let edge = |frame: &crate::fx_visual::EffectQuadFrame| { + std::array::from_fn::<_, 3, _>(|axis| frame.vertices[3][axis] - frame.vertices[0][axis]) + }; + assert_ne!(edge(&default_frames[0]), edge(&unaccelerated_frames[0])); + } + + #[test] + fn op7_group_clocks_advance_only_for_active_groups_and_remain_separate() { + let command = b88_command(2, 0.5); + let identity = [ + [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], + ]; + let mut state = Op7EmitterState::new(37, 37); + let context = render_context(0); + + let first = evaluate_op7_emitter( + &command, + 0, + &identity, + [10.0, 2.0, 10.0], + 0.2, + 0.2, + context, + &mut state, + ) + .expect("first active phase"); + assert_eq!(first.len(), 1); + assert_eq!(state.group_clocks, [0.2, 0.0]); + assert!(matrix_center_z(&first[0]).abs() < 1e-6); + + let inactive = evaluate_op7_emitter( + &command, + 0, + &identity, + [10.0, 2.0, 10.0], + 1.2, + 0.6, + context, + &mut state, + ) + .expect("outside the active window"); + assert!(inactive.is_empty()); + assert_eq!(state.group_clocks, [0.2, 0.0]); + + let resumed = evaluate_op7_emitter( + &command, + 0, + &identity, + [10.0, 2.0, 10.0], + 0.4, + 0.7, + context, + &mut state, + ) + .expect("re-enter the active window"); + assert_eq!(resumed.len(), 2); + assert!((state.group_clocks[0] - 0.3).abs() < 1e-6); + assert!((state.group_clocks[1] - 0.1).abs() < 1e-6); + assert!((matrix_center_z(&resumed[0]) + 0.2).abs() < 1e-5); + assert!(matrix_center_z(&resumed[1]).abs() < 1e-6); + } + + #[test] + fn op7_active_group_clocks_advance_before_detail_cutoff() { + let command = b88_command(2, 0.5); + let identity = [ + [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], + ]; + let mut context = render_context(0); + context.visibility = Op7VisibilityProfile { + width: 0.0, + bias: 1.0, + }; + let mut state = Op7EmitterState::new(37, 37); + let frames = evaluate_op7_emitter( + &command, + 0, + &identity, + [10.0, 2.0, 10.0], + 0.3, + 0.3, + context, + &mut state, + ) + .unwrap(); + + assert!(frames.is_empty()); + assert_eq!(state.group_clocks, [0.3, 0.3]); + } + + #[test] + fn op7_group_phases_match_native_activity_at_zero_and_one() { + let mut command = command(); + let mut body = command.raw_body.to_vec(); + put_f32(&mut body, 0x10, 0.0); + put_f32(&mut body, 0x14, 1.0); + put_u32(&mut body, 0x20, 4); + command.raw_body = Arc::from(body.into_boxed_slice()); + let identity = [ + [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], + ]; + let mut state = Op7EmitterState::new(17, 17); + let mut context = render_context(0); + context.visibility = Op7VisibilityProfile { + width: 0.0, + bias: -1.0, + }; + + let at_zero = evaluate_op7_emitter( + &command, + 0, + &identity, + [0.0, 0.0, -20.0], + 0.0, + 0.0, + context, + &mut state, + ) + .unwrap(); + assert_eq!(at_zero.len(), 1); + assert!((at_zero[0].normalized_time - 0.0).abs() < 1e-6); + + let before_last_group = evaluate_op7_emitter( + &command, + 0, + &identity, + [0.0, 0.0, -20.0], + 0.3749, + 0.0, + context, + &mut state, + ) + .unwrap(); + assert_eq!(before_last_group.len(), 3); + + let at_last_group = evaluate_op7_emitter( + &command, + 0, + &identity, + [0.0, 0.0, -20.0], + 0.375, + 0.0, + context, + &mut state, + ) + .unwrap(); + assert_eq!(at_last_group.len(), 4); + assert!(at_last_group + .iter() + .any(|frame| frame.normalized_time.abs() < 1e-6)); + + let at_one = evaluate_op7_emitter( + &command, + 0, + &identity, + [0.0, 0.0, -20.0], + 0.5, + 0.0, + context, + &mut state, + ) + .unwrap(); + assert_eq!(at_one.len(), 4); + assert!((at_one[0].normalized_time - 1.0).abs() < 1e-6); + + let after_first_group = evaluate_op7_emitter( + &command, + 0, + &identity, + [0.0, 0.0, -20.0], + 0.5001, + 0.0, + context, + &mut state, + ) + .unwrap(); + assert_eq!(after_first_group.len(), 3); + assert!(after_first_group + .iter() + .all(|frame| frame.normalized_time <= 1.0)); + } + + #[test] + fn op7_position_and_scale_randomization_share_the_native_lfsr_stream() { + let mut command = command(); + let mut body = command.raw_body.to_vec(); + put_u32(&mut body, 0x20, 1); + command.raw_body = Arc::from(body.into_boxed_slice()); + let identity = [ + [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], + ]; + let mut state = Op7EmitterState::new(0x1234_5678, 0xBEEF); + let _ = evaluate_op7_emitter( + &command, + 0, + &identity, + [0.0, 0.0, -20.0], + 0.25, + 0.5, + render_context(0), + &mut state, + ) + .unwrap(); + let particle = &state.particles[0]; + assert!((particle.position.end[2] - 6.436_462_4).abs() < 1e-6); + assert!((particle.position.end[1] - 0.069_229_126).abs() < 1e-6); + assert!((particle.position.end[0] - 0.803_848_27).abs() < 1e-6); + assert!((particle.scale.end[2] - 1.818_519_6).abs() < 1e-6); + assert!((particle.scale.end[1] - 2.552_292).abs() < 1e-6); + assert!((particle.scale.end[0] - 1.887_962_3).abs() < 1e-6); + assert_eq!(state.particle_rng, super::Lfsr16Pair { state: 0x6CE1_03C0 }); + } + + #[test] + fn op7_rejects_non_finite_world_scalar() { + let identity = [ + [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], + ]; + let acceleration = b88_command(1, 1.0); + let mut context = render_context(0); + context.world_scalar = f32::NAN; + let mut state = Op7EmitterState::new(11, 11); + assert!(evaluate_op7_emitter( + &acceleration, + 0, + &identity, + [0.0, 0.0, -20.0], + 0.25, + 2.0, + context, + &mut state, + ) + .is_none()); + } + + #[test] + fn op7_rejects_truncated_body() { + let mut command = command(); + command.raw_body = Arc::from( + command.raw_body[..super::MIN_BODY_SIZE - 1] + .to_vec() + .into_boxed_slice(), + ); + let identity = [ + [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], + ]; + let mut state = Op7EmitterState::new(11, 11); + assert!(evaluate_op7_emitter( + &command, + 0, + &identity, + [0.0, 0.0, -20.0], + 0.25, + 2.0, + render_context(0), + &mut state, + ) + .is_none()); + } + + #[test] + fn op7_mode_three_deactivates_on_native_exact_dot_one_precheck() { + let identity = [ + [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], + ]; + // F=(1,0,0), D=(1,1,0): abs(F·D)==1 although D×F is nonzero. + // Native 9667..9683 deactivates before reaching its cross fallback. + assert!( + super::native_mode_three_basis(&identity, [2.0, 1.0, 0.0], [1.0, 0.0, 0.0]).is_none() + ); + } + + #[test] + fn op7_mode_three_uses_038d0_fallback_when_camera_cross_is_zero() { + let identity = [ + [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], + ]; + // F=(0,0,1), D=(0,0,2), so abs(dot)=2 (past the early gate), + // but D×F is zero. 038D0 chooses +Y as the right axis and F×R as up. + assert_eq!( + super::native_mode_three_basis(&identity, [0.0, 0.0, 4.0], [0.0, 0.0, 2.0]), + Some([ + [0.0, 0.0, -1.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 2.0], + [0.0, 0.0, 0.0, 1.0], + ]) + ); + } + + #[test] + fn op7_mode_three_uses_camera_cross_basis_for_non_degenerate_view() { + let identity = [ + [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], + ]; + let matrix = + super::native_mode_three_basis(&identity, [0.0, 0.0, -10.0], [1.0, 1.0, 1.0]).unwrap(); + let inv_sqrt_two = std::f32::consts::FRAC_1_SQRT_2; + assert!((matrix[0][0] - 1.0 / 3.0_f32.sqrt()).abs() < 1e-6); + assert!((matrix[0][1] - inv_sqrt_two).abs() < 1e-6); + assert!((matrix[0][2] - 1.0 / (6.0_f32.sqrt() * 1.0)).abs() < 1e-6); + assert!((matrix[1][0] - 1.0 / 3.0_f32.sqrt()).abs() < 1e-6); + assert!((matrix[1][1] + inv_sqrt_two).abs() < 1e-6); + assert!((matrix[1][2] - 1.0 / (6.0_f32.sqrt() * 1.0)).abs() < 1e-6); + assert!((matrix[2][0] - 1.0 / 3.0_f32.sqrt()).abs() < 1e-6); + assert!(matrix[2][2] + 2.0 / 6.0_f32.sqrt() < 1e-6); + assert_eq!([matrix[0][3], matrix[1][3], matrix[2][3]], [1.0, 1.0, 1.0]); + } + + #[test] + fn op7_mode_three_projects_the_zero_helper_point_through_owner_matrix() { + let identity = [ + [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], + ]; + let translated = [ + [1.0, 0.0, 0.0, 10.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], + ]; + let local = + super::native_mode_three_basis(&identity, [0.0, 0.0, -10.0], [1.0, 1.0, 1.0]).unwrap(); + // FastProc+68 maps world origin to owner-local [-10,0,0]. Pairing it + // with this local slot point and the same owner-local camera yields + // the same forward vector as the untransformed reference case. + let translated_local = + super::native_mode_three_basis(&translated, [0.0, 0.0, -10.0], [-9.0, 1.0, 1.0]) + .unwrap(); + for row in 0..3 { + assert!((translated_local[row][0] - local[row][0]).abs() < 1e-6); + } + } + + #[test] + fn op7_mode_three_evaluator_renders_supported_slots() { + let mut command = command(); + let mut body = command.raw_body.to_vec(); + put_u32(&mut body, 0x00, 1); + put_f32(&mut body, 0x10, 0.0); + put_f32(&mut body, 0x14, 1.0); + put_f32(&mut body, 0x18, 1.0); + put_u32(&mut body, 0x20, 1); + put_vec3(&mut body, 0x28, [1.0, 1.0, 1.0]); + put_vec3(&mut body, 0x34, [1.0, 1.0, 1.0]); + put_vec3(&mut body, 0x40, [0.0; 3]); + put_vec3(&mut body, 0x4C, [1.0; 3]); + put_vec3(&mut body, 0x58, [1.0; 3]); + put_vec3(&mut body, 0x64, [1.0; 3]); + put_vec3(&mut body, 0x70, [0.0; 3]); + put_vec3(&mut body, 0x7C, [1.0; 3]); + command.raw_body = Arc::from(body.into_boxed_slice()); + let identity = [ + [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], + ]; + let mut context = render_context(0); + context.visibility = Op7VisibilityProfile { + width: 0.0, + bias: -1.0, + }; + let mut state = Op7EmitterState::new(9, 9); + let frames = evaluate_op7_emitter( + &command, + 0, + &identity, + [0.0, 0.0, -10.0], + 0.5, + 0.0, + context, + &mut state, + ) + .unwrap(); + assert_eq!(frames.len(), 1); + let center = centroid(&frames[0]); + for (actual, expected) in center.into_iter().zip([1.0, 1.0, 1.0]) { + assert!((actual - expected).abs() < 1e-6); + } + } + + #[test] + fn op7_billboard_uses_owner_local_camera_point_for_translated_owner() { + let mut command = command(); + let mut body = command.raw_body.to_vec(); + put_f32(&mut body, 0x10, 0.0); + put_f32(&mut body, 0x14, 1.0); + put_f32(&mut body, 0x18, 1.0); + put_vec3(&mut body, 0x28, [0.0; 3]); + put_vec3(&mut body, 0x34, [0.0; 3]); + put_vec3(&mut body, 0x40, [0.0; 3]); + put_vec3(&mut body, 0x4C, [1.0; 3]); + put_vec3(&mut body, 0x58, [1.0; 3]); + put_vec3(&mut body, 0x64, [1.0; 3]); + put_vec3(&mut body, 0x70, [0.0; 3]); + put_vec3(&mut body, 0x7C, [1.0; 3]); + put_u32(&mut body, 0x20, 1); + command.raw_body = Arc::from(body.into_boxed_slice()); + + let translated_owner = [ + [1.0, 0.0, 0.0, 10.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], + ]; + let mut context = render_context(0); + context.visibility = Op7VisibilityProfile { + width: 0.0, + bias: -1.0, + }; + let frames = evaluate_op7_emitter( + &command, + 0, + &translated_owner, + [10.0, 0.0, -10.0], + 0.5, + 0.5, + context, + &mut Op7EmitterState::new(7, 7), + ) + .unwrap(); + + assert_eq!(frames.len(), 1); + // Native 07D10 transforms context+48 through the inverse owner matrix + // before 01300 passes it to 93D0. With a translation-only owner that + // leaves a camera directly behind the particle, so the quad is axis + // aligned; raw world coordinates would rotate it around the view ray. + assert_eq!(frames[0].vertices[0], [9.5, -0.5, 0.0]); + assert_eq!(frames[0].vertices[2], [10.5, 0.5, 0.0]); + } + + #[test] + fn op7_is_inactive_outside_its_native_window() { + let command = command(); + let identity = [ + [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], + ]; + let mut state = Op7EmitterState::new(7, 7); + let frames = evaluate_op7_emitter( + &command, + 0, + &identity, + [0.0, 0.0, -1.0], + 0.51, + 0.8, + render_context(0), + &mut state, + ) + .unwrap(); + assert!(frames.is_empty()); + } + + #[test] + fn op7_detail_ordinal_filters_fixed_cutoffs_not_particle_age() { + let command = command(); + let identity = [ + [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], + ]; + // This stream places five of six native cutoffs below level 1 and + // one above it, so the test observes ordinal filtering directly. + let mut state = Op7EmitterState::new(17, 0x0156); + let run = |detail_level, state: &mut Op7EmitterState| { + evaluate_op7_emitter( + &command, + 0, + &identity, + [0.0, 0.0, -20.0], + 0.25, + 0.5, + render_context(detail_level), + state, + ) + .unwrap() + }; + let best = run(0, &mut state); + let lower = run(1, &mut state); + let lowest = run(2, &mut state); + assert_eq!(best.len(), 6); + assert!(lower.len() < best.len()); + assert!(lowest.len() <= lower.len()); + assert!(lower.iter().all(|frame| best + .iter() + .any(|full| frame.normalized_time == full.normalized_time))); + } + + #[test] + #[ignore = "requires the original GOG installation"] + fn original_gog_breath_anl_03a_b_c_load_as_op7() { + let root = std::env::var_os("PARKAN_GOG_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy")); + let bytes = std::fs::read(root.join("effects.rlb")).expect("read effects.rlb"); + let archive = decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible) + .expect("decode effects.rlb"); + let identity = [ + [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], + ]; + for name in ["breath_anl_03a", "breath_anl_03b", "breath_anl_03c"] { + let id = archive.find(name).expect("FXID entry"); + let entry = archive.entry(id).expect("FXID metadata"); + assert_eq!(entry.meta().type_id, FXID_KIND); + let bytes = archive.payload(id).expect("FXID payload"); + let document = decode_fxid(Arc::from(bytes.to_vec().into_boxed_slice())) + .unwrap_or_else(|error| panic!("{name}: {error}")); + let command = document.commands().first().expect("Op7 command"); + assert_eq!(command.opcode, FxOpcode::Op7, "{name}"); + assert_eq!( + super::read_u32(&command.raw_body, 0x00), + Some(0), + "{name} B0" + ); + assert_eq!( + super::read_f32(&command.raw_body, 0x1C), + Some(1.5), + "{name} B1C" + ); + assert_eq!( + super::read_f32(&command.raw_body, 0x88), + Some(0.0), + "{name} B88" + ); + let mut state = Op7EmitterState::new(7, 7); + let frames = evaluate_op7_emitter( + command, + 0, + &identity, + [0.0, 0.0, -20.0], + 0.275, + 0.5, + render_context(0), + &mut state, + ) + .unwrap_or_else(|| panic!("{name}: native Op7 fields should parse")); + assert_eq!(state.particles.len(), 6, "{name} pool slots"); + assert!(frames.len() <= 6, "{name} visible slots"); + assert!(frames.iter().all(|frame| frame.material.name == "smoke_yg")); + } + + let id = archive.find("breath_anl_03").expect("Op8 FXID entry"); + let bytes = archive.payload(id).expect("Op8 FXID payload"); + let document = decode_fxid(Arc::from(bytes.to_vec().into_boxed_slice())) + .expect("decode breath_anl_03"); + assert_eq!(document.commands()[0].opcode, FxOpcode::Op8); + } +} diff --git a/crates/fparkan-fx/src/smoke.rs b/crates/fparkan-fx/src/smoke.rs new file mode 100644 index 0000000..116dcb7 --- /dev/null +++ b/crates/fparkan-fx/src/smoke.rs @@ -0,0 +1,1360 @@ +//! Deterministic evaluator for verified native opcode-8 particle pools. + +use crate::environment::EffectResource; +use crate::fx_visual::{ + compose_native_effect_matrix, native_camera_facing_basis, native_effect_local_point, + transform_native_effect_quad, EffectQuadFrame, EFFECT_QUAD_UV, +}; +use crate::{FxCommand, FxOpcode}; +use std::sync::Arc; + +const RESOURCE_OFFSET: usize = 0xB4; +const RESOURCE_BYTES: usize = 64; +const MIN_BODY_SIZE: usize = RESOURCE_OFFSET + RESOURCE_BYTES; +const MAX_POOL_SIZE: usize = 4096; +const MAX_CATCH_UP: usize = 100_000; +const U16_TO_UNIT: f32 = 1.0 / 65_536.0; + +/// Bounds for the native per-particle detail threshold. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Op8DetailThresholdRange { + /// Lower native threshold bound. + min: f32, + /// Upper native threshold bound. + max: f32, +} + +impl Op8DetailThresholdRange { + /// Returns a range only when both endpoints are finite and ordered. + #[must_use] + pub fn new(min: f32, max: f32) -> Option<Self> { + (min.is_finite() && max.is_finite() && min <= max).then_some(Self { min, max }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct NativeLfsr16Pair { + state: u32, +} + +impl NativeLfsr16Pair { + fn from_word(state: u32) -> Self { + Self { state } + } + + fn next_u16(&mut self) -> u16 { + crate::advance_native_fx_random_state(&mut self.state) + } + + fn next_unit(&mut self) -> f32 { + f32::from(self.next_u16()) * U16_TO_UNIT + } + + fn next_signed(&mut self, width: f32) -> f32 { + (self.next_unit() - 0.5) * width + } +} + +#[derive(Clone, Copy, Debug)] +struct Op8Track { + start: [f32; 3], + end: [f32; 3], + exponent: [f32; 3], +} + +#[derive(Clone, Debug)] +struct ParticleSlot { + born_at: f32, + threshold: f32, + random_phase: f32, + owner_matrix: [[f32; 4]; 4], + position: Op8Track, + scale: Op8Track, +} + +/// Mutable state for one Op8 command. Keep one state per command instance. +#[derive(Clone, Debug)] +pub struct SmokeEmitterState { + initial_emitter_rng: NativeLfsr16Pair, + initial_signed_rng: NativeLfsr16Pair, + initial_material_rng: NativeLfsr16Pair, + emitter_rng: NativeLfsr16Pair, + signed_rng: NativeLfsr16Pair, + material_rng: NativeLfsr16Pair, + body: Option<Arc<[u8]>>, + emitter_position: Option<Op8Track>, + stored_anchor: Option<[f32; 3]>, + cursor: usize, + last_spawn: f32, + interval: f32, + slots: Vec<Option<ParticleSlot>>, +} + +impl SmokeEmitterState { + /// Creates an evaluator with explicit emitter, signed-range, and material seeds. + #[must_use] + pub fn new(emitter_seed: u32, signed_seed: u32, material_seed: u32) -> Self { + let initial_emitter_rng = NativeLfsr16Pair::from_word(emitter_seed); + let initial_signed_rng = NativeLfsr16Pair::from_word(signed_seed); + let initial_material_rng = NativeLfsr16Pair::from_word(material_seed); + Self { + initial_emitter_rng, + initial_signed_rng, + initial_material_rng, + emitter_rng: initial_emitter_rng, + signed_rng: initial_signed_rng, + material_rng: initial_material_rng, + body: None, + emitter_position: None, + stored_anchor: None, + cursor: 0, + last_spawn: 0.0, + interval: 0.0, + slots: Vec::new(), + } + } + + /// Resets the three streams and the native pool state. + fn reset(&mut self) { + self.emitter_rng = self.initial_emitter_rng; + self.signed_rng = self.initial_signed_rng; + self.material_rng = self.initial_material_rng; + self.body = None; + self.emitter_position = None; + self.stored_anchor = None; + self.cursor = 0; + self.last_spawn = 0.0; + self.interval = 0.0; + self.slots.clear(); + } + + /// Number of slots retained by the native ring. + #[must_use] + #[cfg(test)] + fn pool_len(&self) -> usize { + self.slots.len() + } + + /// Native ring cursor. A scheduled event advances this index before access. + #[must_use] + #[cfg(test)] + fn next_slot(&self) -> usize { + self.cursor + } + + fn material_threshold(&mut self, range: Op8DetailThresholdRange) -> f32 { + range.min + self.material_rng.next_unit() * (range.max - range.min) + } +} + +/// Tests the native inclusive detail gate. +#[must_use] +fn detail_class_passes_threshold(detail_class: f32, threshold: f32) -> bool { + detail_class.is_finite() && threshold.is_finite() && detail_class <= threshold +} + +/// Evaluates the native Op8 pool and returns one frame per slot in ring order. +/// +/// The body is parsed from its verified shared offsets, so material names and +/// pool sizes can vary. The caller supplies the native update clock, normalized +/// effect phase, detail threshold bounds, owner transform, camera position, +/// and explicit RNG state. The first in-window call captures the owner pose +/// and emits nothing, matching the native update path. Each emitted particle +/// keeps its interpolated birth anchor and a motion-oriented slot basis. +#[must_use] +#[allow(clippy::too_many_arguments)] +pub fn evaluate_op8_emitter( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + camera_world: [f32; 3], + update_clock: f32, + effect_phase: f32, + detail_class: u32, + detail_threshold_range: Op8DetailThresholdRange, + state: &mut SmokeEmitterState, +) -> Option<Vec<EffectQuadFrame>> { + if command.opcode != FxOpcode::Op8 + || !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !camera_world.iter().all(|value| value.is_finite()) + || !update_clock.is_finite() + || !effect_phase.is_finite() + || !detail_threshold_range.min.is_finite() + || !detail_threshold_range.max.is_finite() + || detail_threshold_range.min > detail_threshold_range.max + || !(detail_threshold_range.max - detail_threshold_range.min).is_finite() + { + return None; + } + + let params = Op8Params::parse(command)?; + if state + .body + .as_deref() + .is_some_and(|body| body != command.raw_body.as_ref()) + { + state.reset(); + } + state.body = Some(Arc::clone(&command.raw_body)); + + if state.slots.len() != params.pool_count { + if !state.slots.is_empty() { + state.reset(); + state.body = Some(Arc::clone(&command.raw_body)); + } + state.slots.resize_with(params.pool_count, || None); + state.cursor = 0; + } + if state.emitter_position.is_none() { + state.emitter_position = Some(sample_emitter_position(¶ms, state)?); + } + + let in_window = effect_phase >= params.window_start && effect_phase <= params.window_end; + let interval_phase = + normalized_window_phase(effect_phase, params.window_start, params.window_end); + let due_interval = + params.interval_start + (params.interval_end - params.interval_start) * interval_phase; + let schedule_interval = + params.interval_start + (params.interval_end - params.interval_start) * effect_phase; + if !due_interval.is_finite() || due_interval <= 0.0 { + return None; + } + let emitter_offset = sample_track(state.emitter_position?, interval_phase)?; + let current_anchor = apply_emitter_offset(world_from_effect, emitter_offset)?; + + if !in_window && !state.slots.iter().any(Option::is_some) { + // The native update returns early once its live-particle count is zero. + // A later in-window update captures a fresh owner anchor before emitting. + state.stored_anchor = None; + state.last_spawn = 0.0; + state.interval = due_interval; + } else if state.last_spawn == 0.0 { + // Native uses a zero schedule base as its initialization sentinel. + // If the first update clock is zero, the next update captures again. + state.stored_anchor = Some(matrix_translation(¤t_anchor)); + state.last_spawn = update_clock; + state.interval = due_interval; + } else { + // Native clamps only the schedule base on a time rewind. Existing + // particles and all three random streams remain intact. + if state.last_spawn > update_clock { + state.last_spawn = update_clock; + } + let catch_up = catch_up_count( + state.last_spawn, + update_clock, + due_interval, + schedule_interval, + )?; + let schedule_origin = state.last_spawn; + let mut stored_anchor = state.stored_anchor?; + let velocity = if catch_up == 0 { + [0.0; 3] + } else { + owner_anchor_velocity( + ¤t_anchor, + stored_anchor, + update_clock, + schedule_origin, + )? + }; + for _ in 0..catch_up { + let schedule_base = state.last_spawn; + let born_at = schedule_base + due_interval; + let next_schedule_base = schedule_base + schedule_interval; + if !born_at.is_finite() { + return None; + } + if !next_schedule_base.is_finite() || next_schedule_base <= schedule_base { + return None; + } + let (owner_matrix, next_anchor) = + advance_owner_anchor(¤t_anchor, stored_anchor, velocity, due_interval)?; + if in_window { + spawn_particle( + ¶ms, + detail_threshold_range, + born_at, + owner_matrix, + state, + )?; + } else { + retire_particle(params.pool_count, state); + } + stored_anchor = next_anchor; + state.stored_anchor = Some(next_anchor); + state.last_spawn = next_schedule_base; + } + state.interval = due_interval; + } + + let interval_period = state.interval * params.pool_count as f32; + if !interval_period.is_finite() || interval_period <= 0.0 { + return None; + } + let mut frames = Vec::with_capacity(params.pool_count); + + for (slot_index, slot) in state.slots.iter().enumerate() { + let Some(particle) = slot else { + frames.push(empty_frame(command_index, ¶ms.resource)); + continue; + }; + let mut raw_phase = (update_clock - state.last_spawn) / interval_period + + (state.cursor as f32 - slot_index as f32) / params.pool_count as f32; + if raw_phase < 0.0 { + raw_phase += 1.0; + } + if !raw_phase.is_finite() { + return None; + } + + let position = sample_track(particle.position, raw_phase)?; + let scale = sample_track(particle.scale, raw_phase)?; + let opacity = params.opacity_start + + (params.opacity_end - params.opacity_start) * raw_phase.powf(params.opacity_exponent); + let material_phase = if params.material_phase_exponent < 0.0 { + particle.random_phase + } else { + raw_phase.powf(params.material_phase_exponent) + }; + if !opacity.is_finite() || !material_phase.is_finite() { + return None; + } + + let local_position = position; + let detail_passes = detail_class_passes_threshold(detail_class as f32, particle.threshold); + let camera_local = native_effect_local_point(&particle.owner_matrix, camera_world)?; + let view = subtract3(camera_local, local_position); + let Some(local_basis) = native_camera_facing_basis(view, local_position) else { + frames.push(EffectQuadFrame { + command_index, + opcode: FxOpcode::Op8, + material: params.resource.clone(), + vertices: [[0.0; 3]; 4], + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, 0.0], + material_phase, + elapsed_seconds: (update_clock - particle.born_at).max(0.0), + normalized_time: raw_phase, + active: false, + native_flag_base: None, + }); + continue; + }; + let matrix = compose_native_effect_matrix( + &particle.owner_matrix, + &local_basis, + local_position, + scale, + )?; + let vertices = transform_native_effect_quad(&matrix)?; + let visible = detail_passes; + frames.push(EffectQuadFrame { + command_index, + opcode: FxOpcode::Op8, + material: params.resource.clone(), + vertices, + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, if visible { opacity } else { 0.0 }], + material_phase, + elapsed_seconds: (update_clock - particle.born_at).max(0.0), + normalized_time: raw_phase, + active: visible, + native_flag_base: None, + }); + } + Some(frames) +} + +#[derive(Clone, Debug)] +struct Op8Params { + resource: EffectResource, + pool_count: usize, + window_start: f32, + window_end: f32, + interval_start: f32, + interval_end: f32, + material_phase_exponent: f32, + opacity_start: f32, + opacity_end: f32, + opacity_exponent: f32, + emitter_position_start: [f32; 3], + emitter_position_end_center: [f32; 3], + emitter_position_width: [f32; 3], + emitter_position_exponent: [f32; 3], + position_start: [f32; 3], + position_end_center: [f32; 3], + position_width: [f32; 3], + position_exponent: [f32; 3], + scale_start: [f32; 3], + scale_end_center: [f32; 3], + scale_width: [f32; 3], + scale_exponent: [f32; 3], +} + +impl Op8Params { + fn parse(command: &FxCommand) -> Option<Self> { + let body = &command.raw_body; + if body.len() < MIN_BODY_SIZE { + return None; + } + let pool_count = read_u32(body, 0x20)? as usize; + let window_start = read_f32(body, 0x0C)?; + let window_end = read_f32(body, 0x10)?; + let interval_start = read_f32(body, 0x14)?; + let interval_end = read_f32(body, 0x18)?; + let material_phase_exponent = read_f32(body, 0x1C)?; + let opacity_start = read_f32(body, 0x00)?; + let opacity_end = read_f32(body, 0x04)?; + let opacity_exponent = read_f32(body, 0x08)?; + let emitter_position_start = read_vec3(body, 0x24)?; + let emitter_position_end_center = read_vec3(body, 0x30)?; + let emitter_position_width = read_vec3(body, 0x3C)?; + let emitter_position_exponent = read_vec3(body, 0x48)?; + let position_start = read_vec3(body, 0x54)?; + let position_end_center = read_vec3(body, 0x60)?; + let position_width = read_vec3(body, 0x6C)?; + let position_exponent = read_vec3(body, 0x78)?; + let scale_start = read_vec3(body, 0x84)?; + let scale_end_center = read_vec3(body, 0x90)?; + let scale_width = read_vec3(body, 0x9C)?; + let scale_exponent = read_vec3(body, 0xA8)?; + if pool_count == 0 + || pool_count > MAX_POOL_SIZE + || ![ + window_start, + window_end, + interval_start, + interval_end, + material_phase_exponent, + opacity_start, + opacity_end, + opacity_exponent, + ] + .iter() + .all(|value| value.is_finite()) + || window_start > window_end + || interval_start <= 0.0 + || interval_end <= 0.0 + || !emitter_position_start + .iter() + .chain(emitter_position_end_center.iter()) + .chain(emitter_position_width.iter()) + .chain(emitter_position_exponent.iter()) + .chain(position_start.iter()) + .chain(position_end_center.iter()) + .chain(position_width.iter()) + .chain(position_exponent.iter()) + .chain(scale_start.iter()) + .chain(scale_end_center.iter()) + .chain(scale_width.iter()) + .chain(scale_exponent.iter()) + .all(|value| value.is_finite()) + { + return None; + } + + Some(Self { + resource: read_resource(body)?, + pool_count, + window_start, + window_end, + interval_start, + interval_end, + material_phase_exponent, + opacity_start, + opacity_end, + opacity_exponent, + emitter_position_start, + emitter_position_end_center, + emitter_position_width, + emitter_position_exponent, + position_start, + position_end_center, + position_width, + position_exponent, + scale_start, + scale_end_center, + scale_width, + scale_exponent, + }) + } +} + +fn normalized_window_phase(phase: f32, start: f32, end: f32) -> f32 { + let phase = (phase - start) / (end - start); + if !(phase >= 0.0) { + 0.0 + } else if phase > 1.0 { + 1.0 + } else { + phase + } +} + +fn catch_up_count( + last_spawn: f32, + update_clock: f32, + due_interval: f32, + schedule_interval: f32, +) -> Option<usize> { + if !last_spawn.is_finite() || !update_clock.is_finite() || !due_interval.is_finite() { + return None; + } + if due_interval <= 0.0 { + return None; + } + + let mut scheduled = last_spawn; + let mut count = 0; + loop { + let due_at = scheduled + due_interval; + if !due_at.is_finite() { + return None; + } + if !(due_at < update_clock) { + return Some(count); + } + if count == MAX_CATCH_UP || !schedule_interval.is_finite() || schedule_interval <= 0.0 { + return None; + } + let next_base = scheduled + schedule_interval; + if !next_base.is_finite() || next_base <= scheduled { + return None; + } + scheduled = next_base; + count += 1; + } +} + +fn sample_emitter_position(params: &Op8Params, state: &mut SmokeEmitterState) -> Option<Op8Track> { + // Native 11343..113A4 draws Z from the uniform stream first and centers + // it around zero, then draws centered Y and X from the signed-range stream. + let mut end = params.emitter_position_end_center; + end[2] += state.emitter_rng.next_unit() * params.emitter_position_width[2] + - params.emitter_position_width[2] * 0.5; + end[1] += state + .signed_rng + .next_signed(params.emitter_position_width[1]); + end[0] += state + .signed_rng + .next_signed(params.emitter_position_width[0]); + end.iter() + .all(|value| value.is_finite()) + .then_some(Op8Track { + start: params.emitter_position_start, + end, + exponent: params.emitter_position_exponent, + }) +} + +fn apply_emitter_offset(owner: &[[f32; 4]; 4], offset: [f32; 3]) -> Option<[[f32; 4]; 4]> { + let mut anchor = *owner; + for axis in 0..3 { + anchor[axis][3] += offset[axis]; + } + anchor + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(anchor) +} + +fn spawn_particle( + params: &Op8Params, + threshold_range: Op8DetailThresholdRange, + born_at: f32, + owner_matrix: [[f32; 4]; 4], + state: &mut SmokeEmitterState, +) -> Option<()> { + let threshold = state.material_threshold(threshold_range); + let random_phase = state.emitter_rng.next_unit(); + let position_z = params.position_end_center[2] + + state.emitter_rng.next_unit() * params.position_width[2] + - params.position_width[2] * 0.5; + let position_y = + params.position_end_center[1] + state.signed_rng.next_signed(params.position_width[1]); + let position_x = + params.position_end_center[0] + state.signed_rng.next_signed(params.position_width[0]); + let scale_z = params.scale_end_center[2] + state.signed_rng.next_signed(params.scale_width[2]); + let scale_y = params.scale_end_center[1] + state.signed_rng.next_signed(params.scale_width[1]); + let scale_x = params.scale_end_center[0] + state.signed_rng.next_signed(params.scale_width[0]); + let position_end = [position_x, position_y, position_z]; + let scale_end = [scale_x, scale_y, scale_z]; + if !threshold.is_finite() + || !position_end.iter().all(|value| value.is_finite()) + || !scale_end.iter().all(|value| value.is_finite()) + || !born_at.is_finite() + { + return None; + } + // The native ring increments its cursor before using the slot. + state.cursor = (state.cursor + 1) % params.pool_count; + state.slots[state.cursor] = Some(ParticleSlot { + born_at, + threshold, + random_phase, + owner_matrix, + position: Op8Track { + start: params.position_start, + end: position_end, + exponent: params.position_exponent, + }, + scale: Op8Track { + start: params.scale_start, + end: scale_end, + exponent: params.scale_exponent, + }, + }); + Some(()) +} + +fn retire_particle(pool_count: usize, state: &mut SmokeEmitterState) { + // Out-of-window ticks follow the same cursor cadence, but mark the slot + // reached by that tick inactive instead of emitting a replacement. + state.cursor = (state.cursor + 1) % pool_count; + state.slots[state.cursor] = None; +} + +fn matrix_translation(matrix: &[[f32; 4]; 4]) -> [f32; 3] { + [matrix[0][3], matrix[1][3], matrix[2][3]] +} + +fn owner_anchor_velocity( + current: &[[f32; 4]; 4], + stored_anchor: [f32; 3], + update_clock: f32, + schedule_base: f32, +) -> Option<[f32; 3]> { + let elapsed = update_clock - schedule_base; + if !elapsed.is_finite() || elapsed <= 0.0 { + return None; + } + let velocity = std::array::from_fn(|axis| (current[axis][3] - stored_anchor[axis]) / elapsed); + velocity + .iter() + .all(|value| value.is_finite()) + .then_some(velocity) +} + +fn advance_owner_anchor( + current: &[[f32; 4]; 4], + stored_anchor: [f32; 3], + velocity: [f32; 3], + due_interval: f32, +) -> Option<([[f32; 4]; 4], [f32; 3])> { + let mut velocity = velocity; + if vector_length_squared(velocity)? == 0.0 { + // Native replaces the zero velocity vector with the current owner's X + // column before both stepping the anchor and building the slot frame. + velocity = [current[0][0], current[1][0], current[2][0]]; + } + let translation = + std::array::from_fn(|axis| stored_anchor[axis] + velocity[axis] * due_interval); + let tangent = normalize_vector(velocity)?; + + let owner_up = [current[0][2], current[1][2], current[2][2]]; + let side_candidate = cross(owner_up, tangent); + let side = if vector_length_squared(side_candidate)? == 0.0 { + native_fallback_side(tangent)? + } else { + normalize_vector(side_candidate)? + }; + let up = cross(tangent, side); + let matrix = [ + [tangent[0], side[0], up[0], translation[0]], + [tangent[1], side[1], up[1], translation[1]], + [tangent[2], side[2], up[2], translation[2]], + [0.0, 0.0, 0.0, 1.0], + ]; + (matrix.iter().flatten().all(|value| value.is_finite()) + && translation.iter().all(|value| value.is_finite())) + .then_some((matrix, translation)) +} + +fn vector_length_squared(vector: [f32; 3]) -> Option<f32> { + let length_squared = vector + .iter() + .map(|component| component * component) + .sum::<f32>(); + length_squared.is_finite().then_some(length_squared) +} + +fn normalize_vector(vector: [f32; 3]) -> Option<[f32; 3]> { + let length_squared = vector_length_squared(vector)?; + if length_squared == 0.0 { + return None; + } + let inverse_length = length_squared.sqrt().recip(); + let normalized = vector.map(|component| component * inverse_length); + normalized + .iter() + .all(|component| component.is_finite()) + .then_some(normalized) +} + +fn native_fallback_side(tangent: [f32; 3]) -> Option<[f32; 3]> { + // Match 38D0: choose X when tangent X is zero, otherwise Y when tangent + // Y is zero, otherwise use (-Y, X, 0) and normalize only when Z is nonzero. + let mut side = if tangent[0] == 0.0 { + [1.0, 0.0, 0.0] + } else if tangent[1] == 0.0 { + [0.0, 1.0, 0.0] + } else { + [-tangent[1], tangent[0], 0.0] + }; + if tangent[0] != 0.0 && tangent[1] != 0.0 && tangent[2] != 0.0 { + side = normalize_vector(side)?; + } + Some(side) +} + +fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] +} + +fn sample_track(track: Op8Track, phase: f32) -> Option<[f32; 3]> { + let sample = std::array::from_fn(|axis| { + track.start[axis] + (track.end[axis] - track.start[axis]) * phase.powf(track.exponent[axis]) + }); + sample + .iter() + .all(|value| value.is_finite()) + .then_some(sample) +} + +fn empty_frame(command_index: usize, resource: &EffectResource) -> EffectQuadFrame { + EffectQuadFrame { + command_index, + opcode: FxOpcode::Op8, + material: resource.clone(), + vertices: [[0.0; 3]; 4], + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, 0.0], + material_phase: 0.0, + elapsed_seconds: 0.0, + normalized_time: 0.0, + active: false, + native_flag_base: None, + } +} + +fn read_resource(body: &[u8]) -> Option<EffectResource> { + let bytes = body.get(RESOURCE_OFFSET..RESOURCE_OFFSET + RESOURCE_BYTES)?; + Some(EffectResource { + archive: read_name(&bytes[..32])?, + name: read_name(&bytes[32..])?, + }) +} + +fn read_name(bytes: &[u8]) -> Option<String> { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + let name = std::str::from_utf8(&bytes[..end]).ok()?.trim(); + (!name.is_empty()).then(|| name.to_owned()) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> { + Some(u32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> { + Some(f32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_vec3(bytes: &[u8], offset: usize) -> Option<[f32; 3]> { + Some([ + read_f32(bytes, offset)?, + read_f32(bytes, offset + 4)?, + read_f32(bytes, offset + 8)?, + ]) +} + +fn subtract3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + std::array::from_fn(|axis| left[axis] - right[axis]) +} + +#[cfg(test)] +mod tests { + use super::{ + detail_class_passes_threshold, evaluate_op8_emitter, Op8DetailThresholdRange, + SmokeEmitterState, + }; + use crate::{fx_visual::EffectQuadFrame, FxCommand, FxOpcode}; + use std::sync::Arc; + + const IDENTITY: [[f32; 4]; 4] = [ + [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], + ]; + + fn command(resource: &str, pool_count: u32, native_flag: bool) -> FxCommand { + let mut body = vec![0_u8; 0xF4]; + put_f32(&mut body, 0x00, 1.0); + put_f32(&mut body, 0x04, 0.0); + put_f32(&mut body, 0x08, 1.0); + put_f32(&mut body, 0x0C, 0.0); + put_f32(&mut body, 0x10, 1.0); + put_f32(&mut body, 0x14, 0.08); + put_f32(&mut body, 0x18, 0.08); + put_f32(&mut body, 0x1C, 1.0); + put_u32(&mut body, 0x20, pool_count); + put_vec3(&mut body, 0x24, [0.0; 3]); + put_vec3(&mut body, 0x30, [0.0; 3]); + put_vec3(&mut body, 0x3C, [0.0; 3]); + put_vec3(&mut body, 0x48, [1.0; 3]); + put_vec3(&mut body, 0x54, [0.0; 3]); + put_vec3(&mut body, 0x60, [0.0, 0.0, 50.0]); + put_vec3(&mut body, 0x6C, [10.0, 30.0, 30.0]); + put_vec3(&mut body, 0x78, [1.0; 3]); + put_vec3(&mut body, 0x84, [10.0; 3]); + put_vec3(&mut body, 0x90, [30.0; 3]); + put_vec3(&mut body, 0x9C, [0.0, 1.0, 1.0]); + put_vec3(&mut body, 0xA8, [1.0; 3]); + put_name(&mut body, 0xB4, "material.lib"); + put_name(&mut body, 0xD4, resource); + FxCommand { + word: if native_flag { 0x108 } else { 8 }, + opcode: FxOpcode::Op8, + native_flag, + raw_body: Arc::from(body), + resource_refs: Vec::new(), + } + } + + fn evaluate( + command: &FxCommand, + clock: f32, + phase: f32, + detail_class: u32, + state: &mut SmokeEmitterState, + ) -> Vec<EffectQuadFrame> { + evaluate_with_owner(command, clock, phase, detail_class, &IDENTITY, state) + } + + fn evaluate_with_owner( + command: &FxCommand, + clock: f32, + phase: f32, + detail_class: u32, + owner: &[[f32; 4]; 4], + state: &mut SmokeEmitterState, + ) -> Vec<EffectQuadFrame> { + evaluate_op8_emitter( + command, + 0, + owner, + [0.0, 0.0, 500.0], + clock, + phase, + detail_class, + Op8DetailThresholdRange::new(1.0, 1.0).unwrap(), + state, + ) + .unwrap() + } + + fn put_u32(body: &mut [u8], offset: usize, value: u32) { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn put_f32(body: &mut [u8], offset: usize, value: f32) { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn put_vec3(body: &mut [u8], offset: usize, value: [f32; 3]) { + for (axis, component) in value.into_iter().enumerate() { + put_f32(body, offset + axis * 4, component); + } + } + + fn put_name(body: &mut [u8], offset: usize, value: &str) { + body[offset..offset + value.len()].copy_from_slice(value.as_bytes()); + } + + fn centroid(frame: &EffectQuadFrame) -> [f32; 3] { + std::array::from_fn(|axis| { + frame + .vertices + .iter() + .map(|vertex| vertex[axis]) + .sum::<f32>() + / 4.0 + }) + } + + fn distance(left: [f32; 3], right: [f32; 3]) -> f32 { + let delta = std::array::from_fn::<_, 3, _>(|axis| left[axis] - right[axis]); + (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt() + } + + #[test] + fn first_update_strict_interval_and_catch_up_match_native_schedule() { + let command = command("fire_smoke", 30, false); + let mut state = SmokeEmitterState::new(13, 13, 13); + assert!(evaluate(&command, 0.0, 0.0, 0, &mut state) + .iter() + .all(|frame| !frame.active)); + assert_eq!(state.next_slot(), 0); + assert_eq!(state.last_spawn, 0.0); + assert_eq!( + evaluate(&command, 0.08, 0.0, 0, &mut state) + .iter() + .filter(|frame| frame.active) + .count(), + 0 + ); + assert_eq!(state.last_spawn, 0.08); + assert_eq!( + evaluate(&command, 0.16, 0.0, 0, &mut state) + .iter() + .filter(|frame| frame.active) + .count(), + 0 + ); + assert_eq!( + evaluate(&command, 0.160_001, 0.0, 0, &mut state) + .iter() + .filter(|frame| frame.active) + .count(), + 1 + ); + assert_eq!( + evaluate(&command, 0.320_001, 0.0, 0, &mut state) + .iter() + .filter(|frame| frame.active) + .count(), + 3 + ); + assert_eq!(state.next_slot(), 3); + } + + #[test] + fn b_sphere_start_uses_normalized_due_interval_and_raw_schedule_step() { + let mut command = command("B_Sphere_Start", 6, true); + let body = Arc::make_mut(&mut command.raw_body); + // These values are from effects.rlb / B_Sphere_Start command 14. + put_f32(body, 0x0C, 0.269); + put_f32(body, 0x10, 0.885); + put_f32(body, 0x14, 0.05); + put_f32(body, 0x18, 0.01); + let mut state = SmokeEmitterState::new(17, 29, 43); + + evaluate(&command, 0.01, 0.5, 0, &mut state); + evaluate(&command, 0.09, 0.5, 0, &mut state); + + // At phase .5, native normalizes to .375 and tests events at .035, + // then .065 relative to the captured .01 base. After each event it + // advances the schedule base by the raw-phase interval .03. + assert!((state.last_spawn - 0.07).abs() < 1e-6); + assert_eq!(state.next_slot(), 2); + assert!((state.slots[1].as_ref().unwrap().born_at - 0.045).abs() < 1e-6); + assert!((state.slots[2].as_ref().unwrap().born_at - 0.075).abs() < 1e-6); + } + + #[test] + fn equal_window_phase_matches_native_unordered_and_clamp_comparisons() { + assert_eq!(super::normalized_window_phase(0.269, 0.269, 0.269), 0.0); + assert_eq!(super::normalized_window_phase(0.268, 0.269, 0.269), 0.0); + assert_eq!(super::normalized_window_phase(0.270, 0.269, 0.269), 1.0); + } + + #[test] + fn clock_rewind_clamps_only_schedule_base() { + let command = command("smoke_yg", 6, true); + let mut state = SmokeEmitterState::new(17, 29, 43); + evaluate(&command, 0.01, 0.5, 0, &mut state); + evaluate(&command, 0.090_001, 0.5, 0, &mut state); + + let cursor = state.cursor; + let born_at = state.slots[1].as_ref().unwrap().born_at; + let emitter_rng = state.emitter_rng; + let signed_rng = state.signed_rng; + let material_rng = state.material_rng; + assert!((state.last_spawn - 0.09).abs() < 1e-6); + + evaluate(&command, 0.04, 0.5, 0, &mut state); + + assert_eq!(state.last_spawn, 0.04); + assert_eq!(state.cursor, cursor); + assert_eq!(state.slots[1].as_ref().unwrap().born_at, born_at); + assert_eq!(state.emitter_rng, emitter_rng); + assert_eq!(state.signed_rng, signed_rng); + assert_eq!(state.material_rng, material_rng); + } + + #[test] + fn catch_up_requires_advancing_base_and_honors_validation_bound() { + assert_eq!(super::catch_up_count(0.0, 1.0, 0.5, 0.25), Some(2)); + assert!(super::catch_up_count(0.0, 200_001.0, 1.0, 1.0).is_none()); + assert!(super::catch_up_count(16_777_216.0, 16_777_220.0, 0.01, 0.01).is_none()); + } + + #[test] + fn moving_owner_advances_from_the_last_emitted_anchor() { + let command = command("smoke_yg", 6, true); + let owner_start = IDENTITY; + let mut owner_end = [ + [0.0, -1.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let mut owner_later = IDENTITY; + owner_end[0][3] = 10.0; + owner_later[0][3] = 20.0; + let mut state = SmokeEmitterState::new(17, 17, 17); + + evaluate_with_owner(&command, 0.01, 0.0, 0, &owner_start, &mut state); + evaluate_with_owner(&command, 0.05, 0.5, 0, &owner_end, &mut state); + assert_eq!(state.stored_anchor.unwrap(), [0.0, 0.0, 0.0]); + + // No scheduled event has happened, so the stored anchor stays at the + // initialization pose. Native divides the full displacement by the + // time from that anchor's schedule base when an event becomes due. + evaluate_with_owner(&command, 0.110_001, 0.5, 0, &owner_end, &mut state); + let slot = state.slots[1].as_ref().unwrap(); + assert!((slot.owner_matrix[0][3] - 8.0).abs() < 1e-4); + assert_eq!( + state.stored_anchor.unwrap(), + [slot.owner_matrix[0][3], 0.0, 0.0] + ); + // The native slot frame uses motion direction and current owner Z. + // The yawed owner with X-directed motion yields an identity basis. + assert_eq!( + slot.owner_matrix[0], + [1.0, 0.0, 0.0, slot.owner_matrix[0][3]] + ); + assert_eq!(slot.owner_matrix[1], [0.0, 1.0, 0.0, 0.0]); + assert_eq!(slot.owner_matrix[2], [0.0, 0.0, 1.0, 0.0]); + + let frames = evaluate_with_owner(&command, 0.12, 0.6, 0, &owner_later, &mut state); + let slot = state.slots[1].as_ref().unwrap(); + let local_position = super::sample_track(slot.position, frames[1].normalized_time).unwrap(); + // A no-event update leaves the previously emitted anchor unchanged. + assert!((slot.owner_matrix[0][3] - 8.0).abs() < 1e-4); + assert!((state.stored_anchor.unwrap()[0] - 8.0).abs() < 1e-4); + let center_x = centroid(&frames[1])[0]; + assert!((center_x - (slot.owner_matrix[0][3] + local_position[0])).abs() < 1e-4); + assert!((center_x - (owner_later[0][3] + local_position[0])).abs() > 10.0); + + let frames = evaluate_with_owner(&command, 0.250_001, 0.7, 0, &owner_later, &mut state); + assert!((state.slots[2].as_ref().unwrap().born_at - 0.17).abs() < 1e-6); + assert!((state.slots[3].as_ref().unwrap().born_at - 0.25).abs() < 1e-6); + // Both catch-up particles use the single velocity computed from the + // stored X=8 anchor and the original schedule base, stepping by .08. + assert!((state.slots[2].as_ref().unwrap().owner_matrix[0][3] - 14.0).abs() < 1e-3); + assert!((state.slots[3].as_ref().unwrap().owner_matrix[0][3] - 20.0).abs() < 1e-3); + assert!(frames[2].active && frames[3].active); + } + + #[test] + fn moving_owner_uses_native_zero_motion_and_parallel_axis_fallbacks() { + let command = command("smoke_yg", 6, true); + let previous = IDENTITY; + let mut current = IDENTITY; + // Tiny nonzero movement must remain a velocity, not take the exact + // zero-motion branch. A parallel owner-up axis then exercises 38D0. + current[0][3] = 0.000_01; + current[0][2] = 1.0; + current[2][2] = 0.0; + current[0][0] = 0.0; + current[1][0] = 1.0; + current[2][0] = 0.0; + let mut state = SmokeEmitterState::new(19, 19, 19); + evaluate_with_owner(&command, 0.01, 0.0, 0, &previous, &mut state); + evaluate_with_owner(&command, 0.090_001, 0.5, 0, ¤t, &mut state); + let matrix = state.slots[1].as_ref().unwrap().owner_matrix; + assert_eq!([matrix[0][0], matrix[1][0], matrix[2][0]], [1.0, 0.0, 0.0]); + assert_eq!([matrix[0][1], matrix[1][1], matrix[2][1]], [0.0, 1.0, 0.0]); + assert_eq!([matrix[0][2], matrix[1][2], matrix[2][2]], [0.0, 0.0, 1.0]); + + // Exactly zero movement uses the current owner X column, matching the + // separate 11B83 branch before the 38D0 parallel-axis fallback. + let mut static_owner = IDENTITY; + static_owner[0][0] = 0.0; + static_owner[1][0] = 1.0; + static_owner[2][0] = 0.0; + let mut stationary = SmokeEmitterState::new(23, 23, 23); + evaluate_with_owner(&command, 0.01, 0.0, 0, &previous, &mut stationary); + evaluate_with_owner(&command, 0.090_001, 0.5, 0, &static_owner, &mut stationary); + let matrix = stationary.slots[1].as_ref().unwrap().owner_matrix; + assert_eq!([matrix[0][0], matrix[1][0], matrix[2][0]], [0.0, 1.0, 0.0]); + assert_eq!([matrix[0][1], matrix[1][1], matrix[2][1]], [-1.0, 0.0, 0.0]); + assert_eq!([matrix[0][2], matrix[1][2], matrix[2][2]], [0.0, 0.0, 1.0]); + assert!((matrix[1][3] - 0.08).abs() < 1e-6); + assert_eq!(stationary.stored_anchor.unwrap(), [0.0, 0.08, 0.0]); + } + + #[test] + fn particles_drain_through_the_ring_and_keep_fading_after_the_window() { + let mut command = command("smoke_yg", 6, true); + put_f32(Arc::make_mut(&mut command.raw_body), 0x10, 0.5); + let mut state = SmokeEmitterState::new(31, 31, 31); + + evaluate(&command, 0.01, 0.25, 0, &mut state); + assert_eq!( + evaluate(&command, 0.090_001, 0.25, 0, &mut state) + .iter() + .filter(|frame| frame.active) + .count(), + 1 + ); + + let tail = evaluate(&command, 0.170_001, 0.75, 0, &mut state); + assert_eq!(tail.iter().filter(|frame| frame.active).count(), 1); + assert!(tail[1].color[3] > 0.0 && tail[1].color[3] < 1.0); + + // Out-of-window events still advance the native ring. This particle + // remains alive until the cursor reaches its slot on the sixth tick. + let before_wrap = evaluate(&command, 0.490_001, 0.75, 0, &mut state); + assert_eq!(before_wrap.iter().filter(|frame| frame.active).count(), 1); + assert!(before_wrap[1].active); + assert!(before_wrap[1].color[3] < tail[1].color[3]); + + let drained = evaluate(&command, 0.570_001, 0.75, 0, &mut state); + assert!(drained.iter().all(|frame| !frame.active)); + assert!(state.slots.iter().all(Option::is_none)); + } + + #[test] + fn generic_six_slot_smoke_yg_body_samples_tracks_opacity_and_phase() { + let mut command = command("smoke_yg", 6, true); + let body = Arc::make_mut(&mut command.raw_body); + put_f32(body, 0x00, 0.25); + put_f32(body, 0x04, 0.75); + put_f32(body, 0x08, 2.0); + put_f32(body, 0x1C, -1.0); + put_f32(body, 0x14, 0.08); + put_f32(body, 0x18, 0.08); + put_f32(body, 0x44, 0.0); + put_vec3(body, 0x48, [1.0; 3]); + put_vec3(body, 0x24, [0.0, 0.0, 3.0]); + put_vec3(body, 0x30, [0.0, 0.0, 3.0]); + put_vec3(body, 0x54, [0.0, 0.0, 3.0]); + put_vec3(body, 0x60, [0.0, 0.0, 13.0]); + put_vec3(body, 0x6C, [0.0, 0.0, 6.0]); + put_vec3(body, 0x78, [1.0, 1.0, 0.5]); + put_vec3(body, 0x84, [0.5, 0.75, 1.0]); + put_vec3(body, 0x90, [1.5, 1.75, 2.0]); + put_vec3(body, 0x9C, [0.0; 3]); + put_vec3(body, 0xA8, [1.0, 2.0, 3.0]); + + let mut state = SmokeEmitterState::new(17, 29, 43); + let first = evaluate(&command, 0.01, 0.0, 1, &mut state); + assert_eq!(first.len(), 6); + assert!(first.iter().all(|frame| !frame.active)); + let frames = evaluate(&command, 0.090_001, 0.5, 1, &mut state); + let frame = &frames[1]; + let particle = state.slots[1].as_ref().unwrap(); + let phase: f32 = (0.090_001 - 0.09) / (0.08 * 6.0); + let position: [f32; 3] = std::array::from_fn(|axis| { + particle.position.start[axis] + + (particle.position.end[axis] - particle.position.start[axis]) + * phase.powf(particle.position.exponent[axis]) + }); + let scale: [f32; 3] = std::array::from_fn(|axis| { + particle.scale.start[axis] + + (particle.scale.end[axis] - particle.scale.start[axis]) + * phase.powf(particle.scale.exponent[axis]) + }); + let expected_center = + crate::fx_visual::transform_native_effect_point(&particle.owner_matrix, position) + .unwrap(); + let expected_opacity = 0.25 + (0.75 - 0.25) * phase.powf(2.0); + let camera_local = + crate::fx_visual::native_effect_local_point(&particle.owner_matrix, [0.0, 0.0, 500.0]) + .unwrap(); + let view = std::array::from_fn(|axis| camera_local[axis] - position[axis]); + let local_basis = super::native_camera_facing_basis(view, position).unwrap(); + let expected_edge_length = |basis_column| { + let edge: [f32; 3] = std::array::from_fn(|row| { + (0..3) + .map(|axis| { + particle.owner_matrix[row][axis] + * scale[axis] + * local_basis[axis][basis_column] + }) + .sum::<f32>() + }); + (edge[0] * edge[0] + edge[1] * edge[1] + edge[2] * edge[2]).sqrt() + }; + + assert!(frame.active); + assert_eq!(frame.material.name, "smoke_yg"); + assert!((frame.normalized_time - phase).abs() < 1e-6); + assert!((frame.material_phase - particle.random_phase).abs() < 1e-6); + assert!((frame.color[3] - expected_opacity).abs() < 1e-6); + for (actual, expected) in centroid(frame).into_iter().zip(expected_center) { + assert!( + (actual - expected).abs() < 1e-4, + "actual={actual}, expected={expected}" + ); + } + assert!( + (distance(frame.vertices[0], frame.vertices[3]) - expected_edge_length(0)).abs() < 1e-4 + ); + assert!( + (distance(frame.vertices[0], frame.vertices[1]) - expected_edge_length(1)).abs() < 1e-4 + ); + } + + #[test] + fn mission02_breath_anl_03_op8_fixture_uses_shared_body_fields() { + let mut command = command("smoke_yg", 6, true); + let body = Arc::make_mut(&mut command.raw_body); + put_f32(body, 0x44, 0.0); + put_vec3(body, 0x54, [0.0, 0.0, 1.0]); + put_vec3(body, 0x60, [0.0, 0.0, 3.0]); + put_vec3(body, 0x6C, [3.0, 2.0, 1.0]); + put_vec3(body, 0x78, [1.0; 3]); + put_vec3(body, 0x84, [4.0; 3]); + put_vec3(body, 0x90, [5.0; 3]); + put_vec3(body, 0x9C, [1.0; 3]); + put_vec3(body, 0xA8, [1.0; 3]); + + let mut state = SmokeEmitterState::new(17, 29, 43); + evaluate(&command, 0.01, 0.0, 1, &mut state); + let frames = evaluate(&command, 0.090_001, 0.5, 1, &mut state); + let frame = &frames[1]; + let particle = state.slots[1].as_ref().unwrap(); + let phase: f32 = (0.090_001 - 0.09) / (0.08 * 6.0); + let position = super::sample_track(particle.position, phase).unwrap(); + + assert!(frame.active); + assert_eq!(frame.material.name, "smoke_yg"); + assert!((frame.normalized_time - phase).abs() < 1e-6); + assert!((frame.material_phase - phase).abs() < 1e-6); + assert!((frame.color[3] - (1.0 - phase)).abs() < 1e-6); + let expected_center = + crate::fx_visual::transform_native_effect_point(&particle.owner_matrix, position) + .unwrap(); + for (actual, expected) in centroid(frame).into_iter().zip(expected_center) { + assert!((actual - expected).abs() < 1e-4); + } + } + + #[test] + fn generic_body_uses_dynamic_resource_pool_and_wraps_ring() { + let command = command("cloud_B2_ADD", 6, false); + let mut left = SmokeEmitterState::new(77, 77, 77); + let mut right = SmokeEmitterState::new(77, 77, 77); + evaluate(&command, 0.01, 0.0, 0, &mut left); + evaluate(&command, 0.01, 0.0, 0, &mut right); + let left_frames = evaluate(&command, 0.570_001, 0.0, 0, &mut left); + let right_frames = evaluate(&command, 0.570_001, 0.0, 0, &mut right); + assert_eq!(left.pool_len(), 6); + assert_eq!(left.next_slot(), 1); + assert_eq!(left_frames, right_frames); + assert_eq!(left_frames[0].material.name, "cloud_B2_ADD"); + } + + #[test] + fn emitter_position_track_moves_birth_anchor_from_start_to_randomized_end() { + let mut command = command("smoke_yg", 6, true); + let body = Arc::make_mut(&mut command.raw_body); + put_vec3(body, 0x24, [1.0, 2.0, 3.0]); + put_vec3(body, 0x30, [11.0, 22.0, 33.0]); + put_vec3(body, 0x3C, [4.0, 6.0, 8.0]); + put_vec3(body, 0x48, [1.0, 2.0, 3.0]); + + let uniform_seed = 0x0001_0000; + let signed_seed = 0x0002_0000; + let mut state = SmokeEmitterState::new(uniform_seed, signed_seed, 1); + evaluate(&command, 0.01, 0.0, 0, &mut state); + let track = state.emitter_position.unwrap(); + let signed = |word: f32, width: f32| (word / 65_536.0 - 0.5) * width; + let expected_end = [ + 11.0 + signed(6.0, 4.0), + 22.0 + signed(3.0, 6.0), + 33.0 + 1.0 / 65_536.0 * 8.0 - 4.0, + ]; + assert_eq!(track.start, [1.0, 2.0, 3.0]); + for (actual, expected) in track.end.into_iter().zip(expected_end) { + assert!((actual - expected).abs() < 1e-6); + } + let mut expected_uniform_rng = super::NativeLfsr16Pair::from_word(uniform_seed); + let mut expected_signed_rng = super::NativeLfsr16Pair::from_word(signed_seed); + expected_uniform_rng.next_unit(); + expected_signed_rng.next_signed(6.0); + expected_signed_rng.next_signed(4.0); + assert_eq!(state.emitter_rng, expected_uniform_rng); + assert_eq!(state.signed_rng, expected_signed_rng); + + // The midpoint uses each component's own exponent, and the sampled + // offset is added to the owner translation before the slot is born. + let midpoint = super::sample_track(track, 0.5).unwrap(); + let mut state = SmokeEmitterState::new(uniform_seed, signed_seed, 1); + evaluate(&command, 0.01, 0.0, 0, &mut state); + let mut owner = IDENTITY; + owner[0][3] = 5.0; + let frames = evaluate_with_owner(&command, 0.090_001, 0.5, 0, &owner, &mut state); + let particle = state.slots[1].as_ref().unwrap(); + let expected_position_z = 50.0 + 4.0 / 65_536.0 * 30.0 - 15.0; + assert!((particle.position.end[2] - expected_position_z).abs() < 1e-6); + // The spawn is almost at the current update, whose owner anchor is + // the trajectory sample at phase 0.5. + for axis in 0..3 { + assert!( + (particle.owner_matrix[axis][3] - (owner[axis][3] + midpoint[axis])).abs() < 1e-3 + ); + } + let local_position = + super::sample_track(particle.position, frames[1].normalized_time).unwrap(); + let expected_center = + crate::fx_visual::transform_native_effect_point(&particle.owner_matrix, local_position) + .unwrap(); + for (actual, expected) in centroid(&frames[1]).into_iter().zip(expected_center) { + assert!((actual - expected).abs() < 1e-3); + } + } + + #[test] + fn detail_gate_is_inclusive_and_rejects_the_next_float() { + assert!(detail_class_passes_threshold(3.0, 3.0)); + let next_float = f32::from_bits(3.0_f32.to_bits() + 1); + assert!(!detail_class_passes_threshold(next_float, 3.0)); + assert!(detail_class_passes_threshold(0.0, 0.0)); + } + + #[test] + fn rejects_malformed_or_oversized_op8_bodies_before_state_changes() { + let command = FxCommand { + word: 8, + opcode: FxOpcode::Op8, + native_flag: false, + raw_body: Arc::from(vec![0_u8; 32]), + resource_refs: Vec::new(), + }; + let mut state = SmokeEmitterState::new(13, 13, 13); + assert!(evaluate_op8_emitter( + &command, + 0, + &IDENTITY, + [0.0, 0.0, 500.0], + 1.0, + 0.0, + 0, + Op8DetailThresholdRange::new(0.0, 3.0).unwrap(), + &mut state, + ) + .is_none()); + assert_eq!(state.pool_len(), 0); + assert_eq!(state.next_slot(), 0); + } +} diff --git a/crates/fparkan-material/src/lib.rs b/crates/fparkan-material/src/lib.rs index 082d10c..d61aa84 100644 --- a/crates/fparkan-material/src/lib.rs +++ b/crates/fparkan-material/src/lib.rs @@ -293,6 +293,8 @@ pub enum MaterialError { MissingMaterial(String), /// A material document has no phases for runtime sampling. EmptyMaterial, + /// A normalized MAT0 sample phase is not finite. + InvalidNormalizedPhase, /// An animation block has no positive duration for a multi-key timeline. InvalidAnimationDuration { /// Selected animation block. @@ -366,6 +368,9 @@ impl std::fmt::Display for MaterialError { Self::Resource(message) => write!(f, "{message}"), Self::MissingMaterial(name) => write!(f, "missing material: {name}"), Self::EmptyMaterial => write!(f, "material has no phases"), + Self::InvalidNormalizedPhase => { + write!(f, "normalized MAT0 phase must be finite") + } Self::InvalidAnimationDuration { block_index } => { write!(f, "MAT0 animation block {block_index} has zero duration") } @@ -670,6 +675,115 @@ pub fn sample_material_phase( }) } +/// Samples a MAT0 animation block from the normalized phase used by native +/// material effects. +/// +/// Unlike [`sample_material_phase`], this path does not interpret the block's +/// animation mode or derive a local time from a clock. It multiplies the +/// normalized phase by the final key end time, truncates to an integer tick, +/// searches key intervals from the end, and interpolates the selected phase +/// with the following phase using the block's native interpolation mask. +/// Finite phases outside `0..=1` use the native fallback phase `0.5`; +/// non-finite inputs are rejected. As with [`sample_material_phase`], a +/// missing block index falls back to block zero and an unanimated document +/// samples its first phase. +/// +/// # Errors +/// +/// Returns an error for a non-finite phase, an empty material, a malformed +/// multi-key duration, non-increasing key end times, or a key that references +/// no phase. +pub fn sample_material_normalized_phase( + document: &Mat0Document, + animation_block_index: usize, + normalized_phase: f32, +) -> Result<MaterialPhaseSample, MaterialError> { + if !normalized_phase.is_finite() { + return Err(MaterialError::InvalidNormalizedPhase); + } + + let normalized_phase = if (0.0..=1.0).contains(&normalized_phase) { + normalized_phase + } else { + 0.5 + }; + + let Some(first_phase) = document.phases.first() else { + return Err(MaterialError::EmptyMaterial); + }; + + let Some((selected_block_index, block)) = document + .animation_blocks + .get(animation_block_index) + .map(|block| (animation_block_index, block)) + .or_else(|| document.animation_blocks.first().map(|block| (0, block))) + else { + return Ok(sample_static_phase(0, first_phase)); + }; + + if block.keys.is_empty() { + return Ok(sample_static_phase(0, first_phase)); + } + + let keys = &block.keys; + let resolve_phase = |key_index: usize, key: &MaterialKey| { + let phase_index = usize::from(key.k0); + document + .phases + .get(phase_index) + .map(|phase| (phase_index, phase)) + .ok_or(MaterialError::InvalidAnimationPhaseIndex { + block_index: selected_block_index, + key_index, + phase_index: key.k0, + phase_count: document.phases.len(), + }) + }; + + if keys.len() == 1 { + let (phase_index, phase) = resolve_phase(0, &keys[0])?; + return Ok(sample_static_phase(phase_index, phase)); + } + + let duration = u32::from(keys[keys.len() - 1].k1); + if duration == 0 { + return Err(MaterialError::InvalidAnimationDuration { + block_index: selected_block_index, + }); + } + validate_key_times(keys, selected_block_index)?; + for (key_index, key) in keys.iter().enumerate() { + resolve_phase(key_index, key)?; + } + + // f64 preserves the exact u16-duration by f32-phase product, matching the + // native x87 extended product before truncation toward zero. + let tick = ((duration as f64) * f64::from(normalized_phase)).trunc() as u32; + let (key_index, previous_end) = select_key_backwards(keys, tick); + let key = &keys[key_index]; + let next_key_index = (key_index + 1) % keys.len(); + let next_key = &keys[next_key_index]; + let (phase_index, phase) = resolve_phase(key_index, key)?; + let (_, next_phase) = resolve_phase(next_key_index, next_key)?; + let span = u32::from(key.k1) - previous_end; + if span == 0 { + return Err(MaterialError::InvalidAnimationKeyTimes { + block_index: selected_block_index, + key_index, + }); + } + // Keep the native endpoint behavior: phase 1 produces `tick == duration`, + // falls back to key zero, and may extrapolate beyond its first interval. + let fraction = tick.wrapping_sub(previous_end) as f32 / span as f32; + let coefficients = + interpolate_coefficients(phase, next_phase, block.interpolation_mask(), fraction); + Ok(MaterialPhaseSample { + phase_index, + coefficients, + texture_raw: phase.texture_raw, + }) +} + fn sample_static_phase(phase_index: usize, phase: &MaterialPhase) -> MaterialPhaseSample { MaterialPhaseSample { phase_index, @@ -711,6 +825,19 @@ fn select_key(keys: &[MaterialKey], local_time_ms: u32) -> (usize, u32) { } } +fn select_key_backwards(keys: &[MaterialKey], tick: u32) -> (usize, u32) { + let mut key_index = keys.len() - 1; + while key_index > 0 { + let start = u32::from(keys[key_index - 1].k1); + let end = u32::from(keys[key_index].k1); + if start <= tick && tick < end { + return (key_index, start); + } + key_index -= 1; + } + (0, 0) +} + fn interpolate_coefficients( left: &MaterialPhase, right: &MaterialPhase, @@ -1363,6 +1490,125 @@ mod tests { } #[test] + fn normalized_material_phase_uses_truncated_tick_and_native_fallback_endpoint() { + let phases = vec![ + phase_with_parameters([0; 18], b"LEFT"), + phase_with_parameters([255; 18], b"RIGHT"), + ]; + let keys = vec![ + MaterialKey { + k0: 0, + k1: 100, + k2: 0, + }, + MaterialKey { + k0: 1, + k1: 200, + k2: 0, + }, + ]; + // RandomPerQuery must not override the explicit normalized phase. + let document = animation_document((0x1f_u32 << 3) | 3, phases, keys); + + let early = sample_material_normalized_phase(&document, 0, 0.25).expect("early"); + assert_eq!(early.phase_index, 0); + assert_eq!(&early.texture_raw[..4], b"LEFT"); + assert!((early.coefficients.additive_rgb[0] - 0.5).abs() < 1e-6); + + // 200 * 0.504 = 100.8; truncation yields tick 100, at the start of key 1. + let truncated = sample_material_normalized_phase(&document, 0, 0.504).expect("tick"); + assert_eq!(truncated.phase_index, 1); + assert_eq!(&truncated.texture_raw[..5], b"RIGHT"); + assert_eq!(truncated.coefficients.additive_rgb[0], 1.0); + + // Native backward search falls through to key zero at the final tick. + let endpoint = sample_material_normalized_phase(&document, 0, 1.0).expect("endpoint"); + assert_eq!(endpoint.phase_index, 0); + assert_eq!(&endpoint.texture_raw[..4], b"LEFT"); + assert!((endpoint.coefficients.additive_rgb[0] - 2.0).abs() < 1e-6); + } + + #[test] + fn normalized_material_phase_preserves_precision_before_native_truncation() { + let document = animation_document( + 1 << 3, + vec![ + phase_with_parameters([0; 18], b"LEFT"), + phase_with_parameters([255; 18], b"RIGHT"), + ], + vec![ + MaterialKey { + k0: 0, + k1: 32_765, + k2: 0, + }, + MaterialKey { + k0: 1, + k1: 32_767, + k2: 0, + }, + ], + ); + + // The exact product is below 32_767; f32 multiplication rounds it up. + let sample = sample_material_normalized_phase(&document, 0, f32::from_bits(0x3f7f_ffff)) + .expect("near endpoint"); + assert_eq!(sample.phase_index, 1); + assert_eq!(&sample.texture_raw[..5], b"RIGHT"); + } + + #[test] + fn normalized_material_phase_uses_half_for_finite_out_of_range_values() { + let document = animation_document( + 1 << 3, + vec![ + phase_with_parameters([0; 18], b"LEFT"), + phase_with_parameters([255; 18], b"RIGHT"), + ], + vec![ + MaterialKey { + k0: 0, + k1: 100, + k2: 0, + }, + MaterialKey { + k0: 1, + k1: 200, + k2: 0, + }, + ], + ); + + let midpoint = sample_material_normalized_phase(&document, 0, 0.5).expect("midpoint"); + let below = sample_material_normalized_phase(&document, 0, -0.1).expect("below"); + let above = sample_material_normalized_phase(&document, 0, 1.1).expect("above"); + assert_eq!(below, midpoint); + assert_eq!(above, midpoint); + } + + #[test] + fn normalized_material_phase_rejects_non_finite_values() { + let document = animation_document( + 0, + vec![phase_with_parameters([0; 18], b"ONLY")], + vec![MaterialKey { + k0: 0, + k1: 0, + k2: 0, + }], + ); + + assert!(matches!( + sample_material_normalized_phase(&document, 0, f32::NAN), + Err(MaterialError::InvalidNormalizedPhase) + )); + assert!(matches!( + sample_material_normalized_phase(&document, 0, f32::INFINITY), + Err(MaterialError::InvalidNormalizedPhase) + )); + } + + #[test] fn material_ping_pong_reverses_local_time_and_clamp_holds_last_phase() { let phases = vec![ phase_with_parameters([0; 18], b"A"), @@ -1510,6 +1756,10 @@ mod tests { sample_material_phase(&invalid_ref, 0, 0, 0, 0), Err(MaterialError::InvalidAnimationPhaseIndex { .. }) )); + assert!(matches!( + sample_material_normalized_phase(&invalid_ref, 0, 0.5), + Err(MaterialError::InvalidAnimationPhaseIndex { .. }) + )); let invalid_times = animation_document( 0, @@ -1531,6 +1781,10 @@ mod tests { sample_material_phase(&invalid_times, 0, 0, 0, 0), Err(MaterialError::InvalidAnimationKeyTimes { .. }) )); + assert!(matches!( + sample_material_normalized_phase(&invalid_times, 0, 0.5), + Err(MaterialError::InvalidAnimationKeyTimes { .. }) + )); let zero_duration = animation_document( 0, @@ -1552,6 +1806,10 @@ mod tests { sample_material_phase(&zero_duration, 0, 0, 0, 0), Err(MaterialError::InvalidAnimationDuration { .. }) )); + assert!(matches!( + sample_material_normalized_phase(&zero_duration, 0, 0.5), + Err(MaterialError::InvalidAnimationDuration { .. }) + )); } #[test] diff --git a/crates/fparkan-terrain/src/contour.rs b/crates/fparkan-terrain/src/contour.rs new file mode 100644 index 0000000..de5077a --- /dev/null +++ b/crates/fparkan-terrain/src/contour.rs @@ -0,0 +1,809 @@ +/// One triangle fragment retained after subtracting a building contour. +/// `source_triangle` indexes the triangle array passed to +/// [`subtract_contour`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct TerrainTriangleFragment { + /// Source triangle index, for preserving its material and vertex channels. + pub source_triangle: usize, + /// Fragment triangle vertices in the input coordinate space. + pub positions: [[f32; 3]; 3], +} + +/// Error returned when a terrain contour cannot be subtracted safely. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum TerrainContourError { + /// The contour is non-finite, degenerate, or self-intersecting. + UnsupportedContour, + /// A source triangle has non-finite coordinates or a degenerate XY plane. + InvalidSourceTriangle { + /// Index of the source triangle. + triangle: usize, + }, +} + +impl std::fmt::Display for TerrainContourError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsupportedContour => write!( + f, + "terrain contour must be finite, simple, and non-degenerate" + ), + Self::InvalidSourceTriangle { triangle } => { + write!( + f, + "terrain source triangle {triangle} is not a finite XY surface" + ) + } + } + } +} + +impl std::error::Error for TerrainContourError {} + +/// Subtracts a finite simple contour using the native per-source-face point set: +/// the three source vertices plus the actual contour segments clipped to that +/// source triangle. It does not extend cuts across the triangle or triangulate +/// the contour into unrelated internal edges. +/// +/// # Errors +/// +/// Returns [`TerrainContourError::UnsupportedContour`] for malformed or +/// self-intersecting contours, and +/// [`TerrainContourError::InvalidSourceTriangle`] when a source face cannot be +/// triangulated or lifted onto its original plane. +pub(crate) fn subtract_contour( + triangles: &[[[f32; 3]; 3]], + contour: &[[f32; 2]], +) -> Result<Vec<TerrainTriangleFragment>, TerrainContourError> { + let ring = normalize_ring(contour)?; + let contour_min: [f64; 2] = std::array::from_fn(|axis| { + ring.iter() + .map(|point| point[axis]) + .fold(f64::INFINITY, f64::min) + }); + let contour_max: [f64; 2] = std::array::from_fn(|axis| { + ring.iter() + .map(|point| point[axis]) + .fold(f64::NEG_INFINITY, f64::max) + }); + let mut output = Vec::new(); + + for (source_triangle, source) in triangles.iter().copied().enumerate() { + if !source.iter().flatten().all(|value| value.is_finite()) { + return Err(TerrainContourError::InvalidSourceTriangle { + triangle: source_triangle, + }); + } + let source_xy = source.map(|point| [f64::from(point[0]), f64::from(point[1])]); + let source_area = cross2(source_xy[0], source_xy[1], source_xy[2]); + if source_area.abs() <= 1.0e-10 { + return Err(TerrainContourError::InvalidSourceTriangle { + triangle: source_triangle, + }); + } + let source_min: [f64; 2] = std::array::from_fn(|axis| { + source_xy + .iter() + .map(|point| point[axis]) + .fold(f64::INFINITY, f64::min) + }); + let source_max: [f64; 2] = std::array::from_fn(|axis| { + source_xy + .iter() + .map(|point| point[axis]) + .fold(f64::NEG_INFINITY, f64::max) + }); + if (0..2).any(|axis| { + source_max[axis] < contour_min[axis] || contour_max[axis] < source_min[axis] + }) { + output.push(TerrainTriangleFragment { + source_triangle, + positions: source, + }); + continue; + } + + let mut vertices = source_xy.to_vec(); + let initial = if source_area > 0.0 { + [0, 1, 2] + } else { + [0, 2, 1] + }; + let mut triangulation = vec![initial]; + let mut clipped_segments = Vec::new(); + for index in 0..ring.len() { + let first = ring[index]; + let second = ring[(index + 1) % ring.len()]; + if let Some((start, end)) = clip_segment_to_triangle(first, second, source_xy) { + let start_id = insert_unique_point(&mut vertices, start); + let end_id = insert_unique_point(&mut vertices, end); + if start_id != end_id { + clipped_segments.push((start_id, end_id)); + } + } + } + + if clipped_segments.is_empty() { + let center = [ + (source_xy[0][0] + source_xy[1][0] + source_xy[2][0]) / 3.0, + (source_xy[0][1] + source_xy[1][1] + source_xy[2][1]) / 3.0, + ]; + if !point_in_ring(center, &ring) { + output.push(TerrainTriangleFragment { + source_triangle, + positions: source, + }); + } + continue; + } + + for point_id in 3..vertices.len() { + insert_triangulation_point(point_id, &vertices, &mut triangulation).map_err(|()| { + TerrainContourError::InvalidSourceTriangle { + triangle: source_triangle, + } + })?; + } + + let mut constraints = source_boundary_constraints(&vertices, source_xy); + for (first, second) in clipped_segments { + let mut on_segment = vertices + .iter() + .copied() + .enumerate() + .filter_map(|(index, point)| { + point_on_segment(point, vertices[first], vertices[second]).then_some(( + segment_parameter(point, vertices[first], vertices[second]), + index, + )) + }) + .collect::<Vec<_>>(); + on_segment.sort_by(|left, right| left.0.total_cmp(&right.0)); + on_segment.dedup_by_key(|(_, index)| *index); + for pair in on_segment.windows(2) { + let edge = canonical_edge(pair[0].1, pair[1].1); + if edge.0 == edge.1 { + continue; + } + recover_constraint(edge, &mut triangulation, &vertices, &constraints).map_err( + |()| TerrainContourError::InvalidSourceTriangle { + triangle: source_triangle, + }, + )?; + constraints.insert(edge); + } + } + legalize_constrained_delaunay(&mut triangulation, &vertices, &constraints).map_err( + |()| TerrainContourError::InvalidSourceTriangle { + triangle: source_triangle, + }, + )?; + + for triangle in triangulation { + let center = triangle.map(|index| vertices[index]); + let center = [ + (center[0][0] + center[1][0] + center[2][0]) / 3.0, + (center[0][1] + center[1][1] + center[2][1]) / 3.0, + ]; + if point_in_ring(center, &ring) { + continue; + } + let mut positions: [[f32; 3]; 3] = triangle + .map(|index| lift_to_source_plane(vertices[index], source)) + .into_iter() + .collect::<Option<Vec<_>>>() + .and_then(|points| points.try_into().ok()) + .ok_or(TerrainContourError::InvalidSourceTriangle { + triangle: source_triangle, + })?; + if source_area < 0.0 { + positions.swap(1, 2); + } + output.push(TerrainTriangleFragment { + source_triangle, + positions, + }); + } + } + Ok(output) +} + +fn normalize_ring(contour: &[[f32; 2]]) -> Result<Vec<[f64; 2]>, TerrainContourError> { + let mut ring = contour + .iter() + .map(|point| [f64::from(point[0]), f64::from(point[1])]) + .collect::<Vec<_>>(); + if ring.iter().flatten().any(|value| !value.is_finite()) { + return Err(TerrainContourError::UnsupportedContour); + } + if ring.len() > 1 && distance_squared(ring[0], *ring.last().expect("nonempty")) <= 1.0e-16 { + ring.pop(); + } + let mut normalized = Vec::with_capacity(ring.len()); + for point in ring { + if normalized + .last() + .is_none_or(|previous| distance_squared(*previous, point) > 1.0e-16) + { + normalized.push(point); + } + } + if normalized.len() > 1 + && distance_squared(normalized[0], *normalized.last().expect("nonempty")) <= 1.0e-16 + { + normalized.pop(); + } + if normalized.len() < 3 { + return Err(TerrainContourError::UnsupportedContour); + } + let signed_area = polygon_area(&normalized); + if !signed_area.is_finite() || signed_area.abs() <= 1.0e-10 { + return Err(TerrainContourError::UnsupportedContour); + } + for first in 0..normalized.len() { + let first_next = (first + 1) % normalized.len(); + for second in (first + 1)..normalized.len() { + let second_next = (second + 1) % normalized.len(); + if first_next == second || second_next == first { + continue; + } + if segments_intersect( + normalized[first], + normalized[first_next], + normalized[second], + normalized[second_next], + ) { + return Err(TerrainContourError::UnsupportedContour); + } + } + } + if signed_area < 0.0 { + normalized.reverse(); + } + Ok(normalized) +} + +fn clip_segment_to_triangle( + first: [f64; 2], + second: [f64; 2], + triangle: [[f64; 2]; 3], +) -> Option<([f64; 2], [f64; 2])> { + let mut triangle = triangle; + if cross2(triangle[0], triangle[1], triangle[2]) < 0.0 { + triangle.swap(1, 2); + } + let mut start = 0.0_f64; + let mut end = 1.0_f64; + for edge in 0..3 { + let edge_first = triangle[edge]; + let edge_second = triangle[(edge + 1) % 3]; + let at_start = cross2(edge_first, edge_second, first); + let at_end = cross2(edge_first, edge_second, second); + let change = at_end - at_start; + if change.abs() <= 1.0e-14 { + if at_start < -1.0e-10 { + return None; + } + continue; + } + let crossing = -at_start / change; + if change > 0.0 { + start = start.max(crossing); + } else { + end = end.min(crossing); + } + if start > end + 1.0e-12 { + return None; + } + } + start = start.clamp(0.0, 1.0); + end = end.clamp(0.0, 1.0); + if end - start <= 1.0e-12 { + return None; + } + Some(( + interpolate(first, second, start), + interpolate(first, second, end), + )) +} + +fn insert_unique_point(vertices: &mut Vec<[f64; 2]>, point: [f64; 2]) -> usize { + // Terrain positions are f32 throughout the native data and renderer. Make + // clipped crossings use that same representable coordinate before building + // the local CDT, so distinct f64 intersections cannot collapse to one XY + // vertex only after fragment emission. + let point = [f64::from(point[0] as f32), f64::from(point[1] as f32)]; + if let Some(index) = vertices + .iter() + .position(|candidate| distance_squared(*candidate, point) <= 1.0e-18) + { + return index; + } + let index = vertices.len(); + vertices.push(point); + index +} + +fn insert_triangulation_point( + point_id: usize, + vertices: &[[f64; 2]], + triangles: &mut Vec<[usize; 3]>, +) -> Result<(), ()> { + let point = vertices[point_id]; + let edge = triangulation_edges(triangles) + .keys() + .copied() + .find(|&(first, second)| point_on_open_segment(point, vertices[first], vertices[second])); + if let Some(edge) = edge { + let mut split = Vec::new(); + triangles.retain(|triangle| { + if triangle_has_edge(*triangle, edge) { + let opposite = triangle + .iter() + .copied() + .find(|index| *index != edge.0 && *index != edge.1) + .expect("triangle has opposite vertex"); + split.push(opposite); + false + } else { + true + } + }); + if split.is_empty() { + return Err(()); + } + for opposite in split { + let first = make_ccw([edge.0, point_id, opposite], vertices).ok_or(())?; + let second = make_ccw([point_id, edge.1, opposite], vertices).ok_or(())?; + triangles.extend([first, second]); + } + return Ok(()); + } + + let containing = triangles.iter().position(|triangle| { + point_in_triangle( + point, + vertices[triangle[0]], + vertices[triangle[1]], + vertices[triangle[2]], + ) + }); + let Some(containing) = containing else { + return Err(()); + }; + let triangle = triangles.swap_remove(containing); + for edge in 0..3 { + let split = make_ccw( + [triangle[edge], triangle[(edge + 1) % 3], point_id], + vertices, + ) + .ok_or(())?; + triangles.push(split); + } + Ok(()) +} + +fn source_boundary_constraints( + vertices: &[[f64; 2]], + triangle: [[f64; 2]; 3], +) -> std::collections::BTreeSet<(usize, usize)> { + let mut constraints = std::collections::BTreeSet::new(); + for edge in 0..3 { + let first = triangle[edge]; + let second = triangle[(edge + 1) % 3]; + let mut points = vertices + .iter() + .copied() + .enumerate() + .filter_map(|(index, point)| { + point_on_segment(point, first, second) + .then_some((segment_parameter(point, first, second), index)) + }) + .collect::<Vec<_>>(); + points.sort_by(|left, right| left.0.total_cmp(&right.0)); + points.dedup_by_key(|(_, index)| *index); + for pair in points.windows(2) { + constraints.insert(canonical_edge(pair[0].1, pair[1].1)); + } + } + constraints +} + +fn recover_constraint( + target: (usize, usize), + triangles: &mut [[usize; 3]], + vertices: &[[f64; 2]], + constraints: &std::collections::BTreeSet<(usize, usize)>, +) -> Result<(), ()> { + let limit = vertices + .len() + .saturating_mul(vertices.len()) + .saturating_mul(8) + .max(1); + for _ in 0..limit { + if edge_exists(triangles, target) { + return Ok(()); + } + let mut crossed = triangulation_edges(triangles) + .into_iter() + .filter_map(|(edge, adjacent)| { + if adjacent.len() != 2 + || edge.0 == target.0 + || edge.0 == target.1 + || edge.1 == target.0 + || edge.1 == target.1 + { + return None; + } + proper_intersection_parameter( + vertices[target.0], + vertices[target.1], + vertices[edge.0], + vertices[edge.1], + ) + .map(|parameter| (parameter, edge, adjacent)) + }) + .collect::<Vec<_>>(); + crossed.sort_by(|left, right| left.0.total_cmp(&right.0)); + let mut flipped = false; + for (_, edge, adjacent) in crossed { + if constraints.contains(&edge) { + return Err(()); + } + let first_index = adjacent[0].0; + let second_index = adjacent[1].0; + let first_opposite = adjacent[0].1; + let second_opposite = adjacent[1].1; + if !convex_quadrilateral( + vertices[edge.0], + vertices[edge.1], + vertices[first_opposite], + vertices[second_opposite], + ) { + continue; + } + let replacement = canonical_edge(first_opposite, second_opposite); + if constraints.contains(&replacement) + || proper_intersection_parameter( + vertices[target.0], + vertices[target.1], + vertices[replacement.0], + vertices[replacement.1], + ) + .is_some() + { + continue; + } + let (a, b) = directed_edge(triangles[first_index], edge).ok_or(())?; + triangles[first_index] = + make_ccw([first_opposite, second_opposite, b], vertices).ok_or(())?; + triangles[second_index] = + make_ccw([second_opposite, first_opposite, a], vertices).ok_or(())?; + flipped = true; + break; + } + if !flipped { + return Err(()); + } + } + Err(()) +} + +fn legalize_constrained_delaunay( + triangles: &mut [[usize; 3]], + vertices: &[[f64; 2]], + constraints: &std::collections::BTreeSet<(usize, usize)>, +) -> Result<(), ()> { + let limit = triangles + .len() + .saturating_mul(triangles.len()) + .saturating_mul(8) + .max(1); + for _ in 0..limit { + let edges = triangulation_edges(triangles); + let mut flipped = false; + for (edge, adjacent) in edges { + if adjacent.len() != 2 || constraints.contains(&edge) { + continue; + } + let first_index = adjacent[0].0; + let second_index = adjacent[1].0; + let c = adjacent[0].1; + let d = adjacent[1].1; + if !convex_quadrilateral(vertices[edge.0], vertices[edge.1], vertices[c], vertices[d]) { + continue; + } + let (a, b) = directed_edge(triangles[first_index], edge).ok_or(())?; + if in_circle(vertices[a], vertices[b], vertices[c], vertices[d]) <= 1.0e-10 { + continue; + } + let replacement = canonical_edge(c, d); + if constraints.iter().any(|constraint| { + proper_intersection_parameter( + vertices[replacement.0], + vertices[replacement.1], + vertices[constraint.0], + vertices[constraint.1], + ) + .is_some() + }) { + continue; + } + triangles[first_index] = make_ccw([c, d, b], vertices).ok_or(())?; + triangles[second_index] = make_ccw([d, c, a], vertices).ok_or(())?; + flipped = true; + break; + } + if !flipped { + return Ok(()); + } + } + Err(()) +} + +fn triangulation_edges( + triangles: &[[usize; 3]], +) -> std::collections::BTreeMap<(usize, usize), Vec<(usize, usize)>> { + let mut edges = std::collections::BTreeMap::new(); + for (triangle_index, triangle) in triangles.iter().copied().enumerate() { + for edge in 0..3 { + let first = triangle[edge]; + let second = triangle[(edge + 1) % 3]; + let opposite = triangle[(edge + 2) % 3]; + edges + .entry(canonical_edge(first, second)) + .or_insert_with(Vec::new) + .push((triangle_index, opposite)); + } + } + edges +} + +fn triangle_has_edge(triangle: [usize; 3], edge: (usize, usize)) -> bool { + triangle.contains(&edge.0) && triangle.contains(&edge.1) +} + +fn edge_exists(triangles: &[[usize; 3]], edge: (usize, usize)) -> bool { + triangles + .iter() + .copied() + .any(|triangle| triangle_has_edge(triangle, edge)) +} + +fn directed_edge(triangle: [usize; 3], edge: (usize, usize)) -> Option<(usize, usize)> { + (0..3).find_map(|index| { + let first = triangle[index]; + let second = triangle[(index + 1) % 3]; + (canonical_edge(first, second) == edge).then_some((first, second)) + }) +} + +fn canonical_edge(first: usize, second: usize) -> (usize, usize) { + if first < second { + (first, second) + } else { + (second, first) + } +} + +fn make_ccw(triangle: [usize; 3], vertices: &[[f64; 2]]) -> Option<[usize; 3]> { + let area = cross2( + vertices[triangle[0]], + vertices[triangle[1]], + vertices[triangle[2]], + ); + if !area.is_finite() || area.abs() <= 1.0e-12 { + return None; + } + Some(if area > 0.0 { + triangle + } else { + [triangle[0], triangle[2], triangle[1]] + }) +} + +fn point_in_triangle(point: [f64; 2], a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> bool { + let edges = [(a, b), (b, c), (c, a)]; + edges.into_iter().all(|(first, second)| { + let scale = distance_squared(first, second).sqrt(); + cross2(first, second, point) >= -linear_tolerance(first, second, point) * scale + }) +} + +fn point_on_open_segment(point: [f64; 2], first: [f64; 2], second: [f64; 2]) -> bool { + if !point_on_segment(point, first, second) { + return false; + } + let t = segment_parameter(point, first, second); + t > 1.0e-9 && t < 1.0 - 1.0e-9 +} + +fn point_on_segment(point: [f64; 2], first: [f64; 2], second: [f64; 2]) -> bool { + let length_squared = distance_squared(first, second); + if length_squared <= 1.0e-20 { + return distance_squared(point, first) <= 1.0e-18; + } + let cross = cross2(first, second, point).abs(); + let dot = (point[0] - first[0]) * (second[0] - first[0]) + + (point[1] - first[1]) * (second[1] - first[1]); + let tolerance = linear_tolerance(first, second, point); + let length = length_squared.sqrt(); + cross <= tolerance * length + && dot >= -tolerance * length + && dot <= length_squared + tolerance * length +} + +fn linear_tolerance(first: [f64; 2], second: [f64; 2], point: [f64; 2]) -> f64 { + let coordinate_scale = first + .into_iter() + .chain(second) + .chain(point) + .map(f64::abs) + .fold(1.0_f64, f64::max); + 2.0 * f64::from(f32::EPSILON) * coordinate_scale +} + +fn segment_parameter(point: [f64; 2], first: [f64; 2], second: [f64; 2]) -> f64 { + let delta = [second[0] - first[0], second[1] - first[1]]; + let length_squared = delta[0] * delta[0] + delta[1] * delta[1]; + if length_squared <= 1.0e-20 { + 0.0 + } else { + ((point[0] - first[0]) * delta[0] + (point[1] - first[1]) * delta[1]) / length_squared + } +} + +fn proper_intersection_parameter( + first: [f64; 2], + second: [f64; 2], + edge_first: [f64; 2], + edge_second: [f64; 2], +) -> Option<f64> { + let first_delta = [second[0] - first[0], second[1] - first[1]]; + let edge_delta = [ + edge_second[0] - edge_first[0], + edge_second[1] - edge_first[1], + ]; + let denominator = first_delta[0] * edge_delta[1] - first_delta[1] * edge_delta[0]; + if denominator.abs() <= 1.0e-12 { + return None; + } + let offset = [edge_first[0] - first[0], edge_first[1] - first[1]]; + let t = (offset[0] * edge_delta[1] - offset[1] * edge_delta[0]) / denominator; + let u = (offset[0] * first_delta[1] - offset[1] * first_delta[0]) / denominator; + (t > 1.0e-9 && t < 1.0 - 1.0e-9 && u > 1.0e-9 && u < 1.0 - 1.0e-9).then_some(t) +} + +fn convex_quadrilateral(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool { + cross2(a, b, c) * cross2(a, b, d) < -1.0e-12 && cross2(c, d, a) * cross2(c, d, b) < -1.0e-12 +} + +fn in_circle(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> f64 { + let a = [a[0] - d[0], a[1] - d[1]]; + let b = [b[0] - d[0], b[1] - d[1]]; + let c = [c[0] - d[0], c[1] - d[1]]; + let a2 = a[0] * a[0] + a[1] * a[1]; + let b2 = b[0] * b[0] + b[1] * b[1]; + let c2 = c[0] * c[0] + c[1] * c[1]; + a2 * (b[0] * c[1] - b[1] * c[0]) - b2 * (a[0] * c[1] - a[1] * c[0]) + + c2 * (a[0] * b[1] - a[1] * b[0]) +} + +fn segments_intersect(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool { + let ab_c = cross2(a, b, c); + let ab_d = cross2(a, b, d); + let cd_a = cross2(c, d, a); + let cd_b = cross2(c, d, b); + let eps = 1.0e-8; + (ab_c.abs() <= eps && point_on_segment(c, a, b)) + || (ab_d.abs() <= eps && point_on_segment(d, a, b)) + || (cd_a.abs() <= eps && point_on_segment(a, c, d)) + || (cd_b.abs() <= eps && point_on_segment(b, c, d)) + || (ab_c * ab_d < 0.0 && cd_a * cd_b < 0.0) +} + +fn point_in_ring(point: [f64; 2], ring: &[[f64; 2]]) -> bool { + let mut winding = 0_i32; + for index in 0..ring.len() { + let first = ring[index]; + let second = ring[(index + 1) % ring.len()]; + let side = cross2(first, second, point); + if first[1] <= point[1] { + if second[1] > point[1] && side > 0.0 { + winding += 1; + } + } else if second[1] <= point[1] && side < 0.0 { + winding -= 1; + } + } + winding != 0 +} + +fn polygon_area(points: &[[f64; 2]]) -> f64 { + (0..points.len()) + .map(|index| { + let first = points[index]; + let second = points[(index + 1) % points.len()]; + first[0] * second[1] - second[0] * first[1] + }) + .sum::<f64>() + * 0.5 +} + +fn lift_to_source_plane(point: [f64; 2], source: [[f32; 3]; 3]) -> Option<[f32; 3]> { + for vertex in source { + if distance_squared(point, [f64::from(vertex[0]), f64::from(vertex[1])]) <= 1.0e-18 { + return Some(vertex); + } + } + let a = source[0].map(f64::from); + let b = source[1].map(f64::from); + let c = source[2].map(f64::from); + let denominator = cross2([a[0], a[1]], [b[0], b[1]], [c[0], c[1]]); + if denominator.abs() <= 1.0e-10 { + return None; + } + let second_weight = cross2([a[0], a[1]], point, [c[0], c[1]]) / denominator; + let third_weight = cross2([a[0], a[1]], [b[0], b[1]], point) / denominator; + let first_weight = 1.0 - second_weight - third_weight; + let z = first_weight * a[2] + second_weight * b[2] + third_weight * c[2]; + let position = [point[0] as f32, point[1] as f32, z as f32]; + position + .iter() + .all(|value| value.is_finite()) + .then_some(position) +} + +fn cross2(first: [f64; 2], second: [f64; 2], third: [f64; 2]) -> f64 { + (second[0] - first[0]) * (third[1] - first[1]) - (second[1] - first[1]) * (third[0] - first[0]) +} + +fn interpolate(first: [f64; 2], second: [f64; 2], amount: f64) -> [f64; 2] { + [ + first[0] + (second[0] - first[0]) * amount, + first[1] + (second[1] - first[1]) * amount, + ] +} + +fn distance_squared(first: [f64; 2], second: [f64; 2]) -> f64 { + let delta_x = first[0] - second[0]; + let delta_y = first[1] - second[1]; + delta_x * delta_x + delta_y * delta_y +} + +#[cfg(test)] +mod tests { + use super::subtract_contour; + + #[test] + fn translated_thin_outside_strip_keeps_its_area() { + let base = 10_000.0_f32; + let source = [[ + [base, base, 0.0], + [base + 10.0, base, 0.0], + [base, base + 10.0, 0.0], + ]]; + let outer = [ + [base + 0.003, base + 0.003], + [base + 1.0, base + 0.003], + [base + 1.0, base + 0.997], + [base + 0.003, base + 0.997], + ]; + let output = subtract_contour(&source, &outer).expect("simple contour"); + let retained_area = output + .iter() + .map(|fragment| { + let [a, b, c] = fragment.positions; + let ab = [f64::from(b[0] - a[0]), f64::from(b[1] - a[1])]; + let ac = [f64::from(c[0] - a[0]), f64::from(c[1] - a[1])]; + (ab[0] * ac[1] - ab[1] * ac[0]).abs() * 0.5 + }) + .sum::<f64>(); + let expected_area = + 50.0 - (f64::from(outer[1][0] - outer[0][0]) * f64::from(outer[2][1] - outer[1][1])); + assert!( + (retained_area - expected_area).abs() < 0.001, + "retained area {retained_area}, expected {expected_area}" + ); + } +} diff --git a/crates/fparkan-terrain/src/foundation.rs b/crates/fparkan-terrain/src/foundation.rs new file mode 100644 index 0000000..594bbfe --- /dev/null +++ b/crates/fparkan-terrain/src/foundation.rs @@ -0,0 +1,1806 @@ +//! Native-shaped building basement foundation geometry. + +use crate::contour::subtract_contour; +use fparkan_terrain_format::{FullSurfaceMask, LandMeshDocument, TerrainLod}; + +const LINE_EPSILON: f64 = 1.0e-5; +const POINT_EPSILON_SQUARED: f64 = 1.0e-12; + +/// A terrain or generated basement triangle shared by preview consumers. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BasementFoundationFragment { + /// Original Land.msh face for terrain fragments; `None` for generated band faces. + pub source_face: Option<usize>, + /// Raw terrain or generated basement face flags. + pub surface_mask: FullSurfaceMask, + /// Triangle coordinates shared by rendering, shadows, and terrain queries. + pub positions: [[f32; 3]; 3], +} + +/// Canonical output of the native-shaped terrain basement preparation. +#[derive(Clone, Debug, PartialEq)] +pub struct BasementFoundation { + pub fragments: Vec<BasementFoundationFragment>, + /// Native placement-height inputs in footprint order. + pub placements: Vec<BasementPlacement>, +} + +/// One building footprint in native preparation order. +#[derive(Clone, Copy, Debug)] +pub struct BasementFootprint<'a> { + /// Authored outside ring in world XY coordinates. + pub outer: &'a [[f32; 2]], + /// Authored inner ring, retaining its source Z for placement calculations. + pub inner: &'a [[f32; 3]], + /// Whether native getter mode flattens the generated inner boundary to mean outer Z. + pub flatten_inner_to_mean_outer_z: bool, +} + +/// Native placement-height inputs captured for one building. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BasementPlacement { + /// Native transform adjustment: first authored inner Z minus mean sampled outer terrain Z. + pub placement_delta: f32, +} + +/// Error returned while preparing a basement foundation. +#[derive(Clone, Debug, PartialEq)] +pub enum BasementFoundationError { + /// Terrain Node38/Slot68 data cannot resolve LOD0 faces. + InvalidLodMapping(fparkan_terrain_format::TerrainLodError), + /// A selected LOD0 face refers to a missing vertex. + InvalidTerrainFace { face: usize, vertex: u16 }, + /// A contour, nested-ring relationship, or generated annulus is invalid. + InvalidContour, + /// The LOD0 mesh has no usable terrain faces. + EmptyLod0, + /// The outer contour did not cross any LOD0 face. + NoOuterCrossings, + /// A point of the authored outer contour lies outside the LOD0 terrain. + OuterPointOutsideLod0 { point: usize }, + /// An authored outer segment leaves the current terrain surface. + UncoveredOuterSegment { segment: usize }, + /// Footprint outer contours overlap; native overlap semantics remain unverified. + OverlappingFootprints, +} + +impl std::fmt::Display for BasementFoundationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidLodMapping(error) => write!(f, "invalid terrain LOD mapping: {error}"), + Self::InvalidTerrainFace { face, vertex } => { + write!(f, "terrain face {face} references missing vertex {vertex}") + } + Self::InvalidContour => write!(f, "invalid basement contour geometry"), + Self::EmptyLod0 => write!(f, "terrain has no LOD0 faces"), + Self::NoOuterCrossings => write!(f, "outer contour does not cross LOD0 terrain"), + Self::OuterPointOutsideLod0 { point } => { + write!(f, "outer contour point {point} is outside LOD0 terrain") + } + Self::UncoveredOuterSegment { segment } => { + write!( + f, + "outer contour segment {segment} leaves the terrain surface" + ) + } + Self::OverlappingFootprints => { + write!(f, "overlapping basement footprints are not supported") + } + } + } +} + +impl std::error::Error for BasementFoundationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidLodMapping(error) => Some(error), + Self::InvalidTerrainFace { .. } + | Self::InvalidContour + | Self::EmptyLod0 + | Self::NoOuterCrossings => None, + Self::OuterPointOutsideLod0 { .. } | Self::UncoveredOuterSegment { .. } => None, + Self::OverlappingFootprints => None, + } + } +} + +/// Строит основания ландшафта в порядке нативной подготовки размещений. +/// +/// Вершины внешнего контура нормализуются по топологии LOD0: сначала проверяются +/// вершины граней и соседних граней, затем применяется проекция, используемая +/// нативным `FUN_10006240`. Это косинусная формула с отдельной проверкой +/// расстояния, а не обычная ортогональная проекция. При обходе сегмента +/// вставляются только вершины LOD0, прошедшие тот же нативный предикат и +/// находящиеся не ближе 0.5 мировых единиц к обоим концам; геометрическое +/// совпадение с отрезком само по себе недостаточно. +/// +/// Для каждого контура функция упорядочивает пересечения с плоскостями текущих +/// канонических треугольников, вычисляет среднюю высоту внешней границы и строит +/// ограниченную триангуляцию кольца. Явный флаг выбирает нативную ветвь высоты +/// внутренней границы. Непересекающиеся контуры последовательно вырезаются из +/// текущей поверхности; пересекающиеся контуры отклоняются, поскольку их +/// взаимодействие в нативном коде пока не установлено. +/// +/// Возвращённый набор фрагментов является общей геометрией для рендеринга, +/// теней и запросов поверхности. Фрагменты исходной поверхности сохраняют +/// номера и маски исходных граней; у сгенерированных граней маска соответствует +/// записи `AddBasementFaceProc`. +/// +/// The returned fragment vector is the canonical geometry for rendering, +/// shadows, and terrain queries. Source faces preserve their original face IDs +/// and masks. Generated faces use the raw mask written by AddBasementFaceProc. +pub fn build_basement_foundations( + mesh: &LandMeshDocument, + footprints: &[BasementFootprint<'_>], +) -> Result<BasementFoundation, BasementFoundationError> { + let assignments = mesh + .face_lod_assignments() + .map_err(BasementFoundationError::InvalidLodMapping)?; + let mut lod0_face_indices = Vec::new(); + let mut terrain = Vec::new(); + for assignment in assignments + .iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod0) + { + let face = &mesh.faces[assignment.face_index]; + let positions = terrain_face_positions(mesh, assignment.face_index)?; + terrain.push(BasementFoundationFragment { + source_face: Some(assignment.face_index), + surface_mask: face.flags, + positions, + }); + lod0_face_indices.push(assignment.face_index); + } + if terrain.is_empty() { + return Err(BasementFoundationError::EmptyLod0); + } + + let mut bands = Vec::new(); + let mut placements = Vec::with_capacity(footprints.len()); + let mut previous_outer_rings: Vec<Vec<[f64; 2]>> = Vec::with_capacity(footprints.len()); + for footprint in footprints { + let (outer_points, outer_closed) = normalize_ring(footprint.outer)?; + let snapped_outer = normalize_outer_points(&outer_points, &lod0_face_indices, mesh)?; + if !simple_ring(&snapped_outer) { + return Err(BasementFoundationError::InvalidContour); + } + if previous_outer_rings + .iter() + .any(|previous| rings_overlap(previous, &snapped_outer)) + { + return Err(BasementFoundationError::OverlappingFootprints); + } + previous_outer_rings.push(snapped_outer.clone()); + let mut outer_ring = snapped_outer + .iter() + .map(|point| [point[0] as f32, point[1] as f32]) + .collect::<Vec<_>>(); + if outer_closed { + outer_ring.push(outer_ring[0]); + } + let samples = terrain + .iter() + .map(|fragment| { + ( + fragment + .source_face + .expect("terrain fragments have source faces"), + fragment.surface_mask, + fragment.positions, + ) + }) + .collect::<Vec<_>>(); + let mut outer_crossings = contour_crossings(&snapped_outer, &samples)?; + if outer_crossings.is_empty() { + return Err(BasementFoundationError::NoOuterCrossings); + } + // Crossings are open here; restore the authored duplicate only afterward. + let mean_outer_z = outer_crossings + .iter() + .map(|crossing| f64::from(crossing[2])) + .sum::<f64>() + / outer_crossings.len() as f64; + if !mean_outer_z.is_finite() { + return Err(BasementFoundationError::InvalidContour); + } + let mean_outer_z = mean_outer_z as f32; + if outer_closed { + let closing = *outer_crossings + .first() + .ok_or(BasementFoundationError::NoOuterCrossings)?; + outer_crossings.push(closing); + } + + let inner_xy = footprint + .inner + .iter() + .map(|point| [point[0], point[1]]) + .collect::<Vec<_>>(); + let (inner_points, inner_closed) = normalize_ring(&inner_xy)?; + if footprint + .inner + .iter() + .flatten() + .any(|value| !value.is_finite()) + || !nested_rings(&snapped_outer, &inner_points) + { + return Err(BasementFoundationError::InvalidContour); + } + let authored_inner_z = footprint + .inner + .first() + .ok_or(BasementFoundationError::InvalidContour)?[2]; + let placement_delta = authored_inner_z - mean_outer_z; + if !placement_delta.is_finite() { + return Err(BasementFoundationError::InvalidContour); + } + placements.push(BasementPlacement { placement_delta }); + let inner_vertex_count = if inner_closed { + footprint.inner.len() - 1 + } else { + footprint.inner.len() + }; + let mut inner_ring = footprint.inner[..inner_vertex_count] + .iter() + .map(|point| { + [ + point[0], + point[1], + if footprint.flatten_inner_to_mean_outer_z { + mean_outer_z + } else { + point[2] + }, + ] + }) + .collect::<Vec<_>>(); + if inner_closed { + inner_ring.push(inner_ring[0]); + } + let outer_surface = crossings_as_ring(&outer_crossings, outer_closed)?; + let inner_surface = inner_ring_without_closure(&inner_ring); + for positions in triangulate_annulus(&outer_surface, inner_surface)? { + bands.push(BasementFoundationFragment { + source_face: None, + surface_mask: basement_surface_mask(positions), + positions, + }); + } + + let source_positions = terrain + .iter() + .map(|fragment| fragment.positions) + .collect::<Vec<_>>(); + let outside = subtract_contour(&source_positions, &outer_ring) + .map_err(|_| BasementFoundationError::InvalidContour)?; + let clipped = outside + .into_iter() + .map(|fragment| { + let source = terrain[fragment.source_triangle]; + BasementFoundationFragment { + source_face: source.source_face, + surface_mask: source.surface_mask, + positions: fragment.positions, + } + }) + .collect(); + terrain = clipped; + } + terrain.extend(bands); + Ok(BasementFoundation { + fragments: terrain, + placements, + }) +} + +fn terrain_face_positions( + mesh: &LandMeshDocument, + face_index: usize, +) -> Result<[[f32; 3]; 3], BasementFoundationError> { + let face = &mesh.faces[face_index]; + let resolve = |vertex: u16| { + mesh.positions.get(usize::from(vertex)).copied().ok_or( + BasementFoundationError::InvalidTerrainFace { + face: face_index, + vertex, + }, + ) + }; + Ok([ + resolve(face.vertices[0])?, + resolve(face.vertices[1])?, + resolve(face.vertices[2])?, + ]) +} + +fn normalize_ring(ring: &[[f32; 2]]) -> Result<(Vec<[f64; 2]>, bool), BasementFoundationError> { + if ring.iter().flatten().any(|value| !value.is_finite()) { + return Err(BasementFoundationError::InvalidContour); + } + let mut points = ring + .iter() + .map(|point| [f64::from(point[0]), f64::from(point[1])]) + .collect::<Vec<_>>(); + let closed = points.len() > 1 + && distance_squared(points[0], *points.last().expect("nonempty")) <= POINT_EPSILON_SQUARED; + if closed { + points.pop(); + } + if points.len() < 3 || !simple_ring(&points) { + return Err(BasementFoundationError::InvalidContour); + } + Ok((points, closed)) +} + +fn normalize_outer_points( + points: &[[f64; 2]], + lod0_faces: &[usize], + mesh: &LandMeshDocument, +) -> Result<Vec<[f64; 2]>, BasementFoundationError> { + // shortcut: first-face lookup and neighbor ordering still use source LOD0 + // IDs for every placement. The crossing sampler and cutter use the current + // canonical fragment list, but dynamic neighbor-ID reconstruction is not + // modeled yet, so later placements are not claimed as native-identical. + let mut output = points + .iter() + .enumerate() + .map(|(point_index, point)| { + native_normalize_outer_point( + mesh, + lod0_faces, + [point[0] as f32, point[1] as f32], + point_index, + ) + }) + .collect::<Result<Vec<_>, _>>()? + .into_iter() + .map(|point| [f64::from(point[0]), f64::from(point[1])]) + .collect::<Vec<_>>(); + + // The native contour pass asks for terrain faces overlapping the current + // segment's AABB, then tries their three vertices in face order. On one + // accepted vertex it inserts the full terrain point and resumes from the + // shortened segment at the same index. + let mut segment_index = 0; + while segment_index < output.len() { + let first = output[segment_index].map(|value| value as f32); + let second = output[(segment_index + 1) % output.len()].map(|value| value as f32); + let min = [first[0].min(second[0]), first[1].min(second[1])]; + let max = [first[0].max(second[0]), first[1].max(second[1])]; + let mut inserted = false; + 'faces: for &face_index in lod0_faces { + let Some(face) = mesh.faces.get(face_index) else { + continue; + }; + let triangle = face + .vertices + .map(|vertex| mesh.positions.get(usize::from(vertex)).copied()); + let [Some(a), Some(b), Some(c)] = triangle else { + continue; + }; + let face_min = [a[0].min(b[0]).min(c[0]), a[1].min(b[1]).min(c[1])]; + let face_max = [a[0].max(b[0]).max(c[0]), a[1].max(b[1]).max(c[1])]; + if (0..2).any(|axis| face_max[axis] < min[axis] || max[axis] < face_min[axis]) { + continue; + } + for vertex in [a, b, c] { + if !native_segment_inserts_vertex(first, second, vertex) { + continue; + } + let vertex_xy = [f64::from(vertex[0]), f64::from(vertex[1])]; + if output + .iter() + .any(|existing| distance_squared(*existing, vertex_xy) <= POINT_EPSILON_SQUARED) + { + continue; + } + output.insert(segment_index + 1, vertex_xy); + inserted = true; + break 'faces; + } + } + if !inserted { + segment_index += 1; + } + } + Ok(output) +} + +fn native_normalize_outer_point( + mesh: &LandMeshDocument, + lod0_faces: &[usize], + point: [f32; 2], + point_index: usize, +) -> Result<[f32; 2], BasementFoundationError> { + let containing_face = lod0_faces.iter().copied().find(|&face_index| { + let Some(face) = mesh.faces.get(face_index) else { + return false; + }; + let triangle = face + .vertices + .map(|vertex| mesh.positions[usize::from(vertex)]); + point_in_triangle_xy(point, triangle) + }); + let containing_face = containing_face + .ok_or(BasementFoundationError::OuterPointOutsideLod0 { point: point_index })?; + let mut candidates = vec![containing_face]; + for neighbor in mesh.faces[containing_face].neighbors.iter().flatten() { + let neighbor = usize::from(*neighbor); + if lod0_faces.contains(&neighbor) && !candidates.contains(&neighbor) { + candidates.push(neighbor); + } + } + let normalized = point; + for face_index in candidates { + let face = &mesh.faces[face_index]; + let vertices = face + .vertices + .map(|vertex| mesh.positions.get(usize::from(vertex)).copied()); + let [Some(a), Some(b), Some(c)] = vertices else { + return Err(BasementFoundationError::InvalidTerrainFace { + face: face_index, + vertex: face.vertices[0], + }); + }; + let vertices = [a, b, c]; + for (first, second) in [(0, 1), (1, 2), (2, 0)] { + if let Some(candidate) = + native_normalize_edge(normalized, vertices[first], vertices[second])? + { + return Ok(candidate); + } + } + } + Ok(normalized) +} + +fn point_in_triangle_xy(point: [f32; 2], triangle: [[f32; 3]; 3]) -> bool { + let sign = |a: [f32; 3], b: [f32; 3]| { + (point[0] - b[0]) * (a[1] - b[1]) - (a[0] - b[0]) * (point[1] - b[1]) + }; + let d1 = sign(triangle[0], triangle[1]); + let d2 = sign(triangle[1], triangle[2]); + let d3 = sign(triangle[2], triangle[0]); + let has_negative = d1 < -1.0e-4 || d2 < -1.0e-4 || d3 < -1.0e-4; + let has_positive = d1 > 1.0e-4 || d2 > 1.0e-4 || d3 > 1.0e-4; + !(has_negative && has_positive) +} + +fn native_normalize_edge( + point: [f32; 2], + first: [f32; 3], + second: [f32; 3], +) -> Result<Option<[f32; 2]>, BasementFoundationError> { + if distance_xy(point, [first[0], first[1]]) < 0.5 { + let point = [first[0], first[1]]; + return Ok(Some(point)); + } + if distance_xy(point, [second[0], second[1]]) < 0.5 { + let point = [second[0], second[1]]; + return Ok(Some(point)); + } + let Some((offset, _)) = native_edge_projection(point, first, second) else { + return Ok(None); + }; + let mut projected = [point[0] + offset[0], point[1] + offset[1]]; + let Some((check_offset, _)) = native_edge_projection(projected, first, second) else { + return Err(BasementFoundationError::InvalidContour); + }; + if check_offset[0].hypot(check_offset[1]) >= 0.0001 { + return Err(BasementFoundationError::InvalidContour); + } + if distance_xy(projected, [first[0], first[1]]) < 0.5 { + projected = [first[0], first[1]]; + } + if distance_xy(projected, [second[0], second[1]]) < 0.5 { + projected = [second[0], second[1]]; + } + Ok(Some(projected)) +} + +fn native_segment_inserts_vertex(first: [f32; 2], second: [f32; 2], point: [f32; 3]) -> bool { + if distance_xy([point[0], point[1]], first) < 0.5 + || distance_xy([point[0], point[1]], second) < 0.5 + { + return false; + } + native_edge_projection( + [point[0], point[1]], + [first[0], first[1], 0.0], + [second[0], second[1], 0.0], + ) + .is_some() +} + +fn native_edge_projection( + point: [f32; 2], + first: [f32; 3], + second: [f32; 3], +) -> Option<([f32; 2], f32)> { + let ux = point[0] - first[0]; + let uy = point[1] - first[1]; + let ex = second[0] - first[0]; + let ey = second[1] - first[1]; + let edge_length = (ex * ex + ey * ey).sqrt(); + let point_length = (ux * ux + uy * uy).sqrt(); + if edge_length == 0.0 || point_length == 0.0 { + return None; + } + let t = ((ex * ux + ey * uy) / edge_length) / point_length; + if !(0.0..=1.0).contains(&t) { + return None; + } + let offset = [ + (t * ex + first[0]) - point[0], + (t * ey + first[1]) - point[1], + ]; + (offset[0].hypot(offset[1]) < 0.5).then_some((offset, t)) +} + +fn distance_xy(first: [f32; 2], second: [f32; 2]) -> f32 { + ((first[0] - second[0]).powi(2) + (first[1] - second[1]).powi(2)).sqrt() +} + +fn contour_crossings( + ring: &[[f64; 2]], + terrain_faces: &[(usize, FullSurfaceMask, [[f32; 3]; 3])], +) -> Result<Vec<[f32; 3]>, BasementFoundationError> { + let mut crossings = Vec::new(); + for segment_index in 0..ring.len() { + let first = ring[segment_index]; + let second = ring[(segment_index + 1) % ring.len()]; + let mut segment_crossings = Vec::new(); + let mut coverage = Vec::new(); + for (source_face, _, triangle) in terrain_faces { + let Some((start, end)) = segment_triangle_interval(first, second, *triangle) else { + continue; + }; + coverage.push((start, end)); + for t in [start, end] { + let point = [ + first[0] + (second[0] - first[0]) * t, + first[1] + (second[1] - first[1]) * t, + ]; + let Some(position) = lift_to_face(point, *triangle) else { + continue; + }; + segment_crossings.push((t, *source_face, position)); + } + } + segment_crossings.sort_by(|left, right| { + left.0 + .total_cmp(&right.0) + .then_with(|| left.1.cmp(&right.1)) + }); + coverage.sort_by(|left, right| { + left.0 + .total_cmp(&right.0) + .then_with(|| left.1.total_cmp(&right.1)) + }); + let mut covered_until = 0.0_f64; + for (start, end) in coverage { + if start > covered_until + 1.0e-8 { + return Err(BasementFoundationError::UncoveredOuterSegment { + segment: segment_index, + }); + } + covered_until = covered_until.max(end); + } + if covered_until < 1.0 - 1.0e-8 { + return Err(BasementFoundationError::UncoveredOuterSegment { + segment: segment_index, + }); + } + for (_, _, position) in segment_crossings { + let duplicate_of_previous = crossings.last().is_some_and(|previous: &[f32; 3]| { + distance_squared( + [f64::from(previous[0]), f64::from(previous[1])], + [f64::from(position[0]), f64::from(position[1])], + ) <= POINT_EPSILON_SQUARED + }); + if duplicate_of_previous { + continue; + } + crossings.push(position); + } + } + if crossings.len() > 1 + && distance_squared( + [f64::from(crossings[0][0]), f64::from(crossings[0][1])], + [ + f64::from(crossings.last().expect("nonempty")[0]), + f64::from(crossings.last().expect("nonempty")[1]), + ], + ) <= POINT_EPSILON_SQUARED + { + crossings.pop(); + } + Ok(crossings) +} + +fn segment_triangle_interval( + first: [f64; 2], + second: [f64; 2], + triangle: [[f32; 3]; 3], +) -> Option<(f64, f64)> { + let vertices = triangle.map(|point| [f64::from(point[0]), f64::from(point[1])]); + let area = cross2(vertices[0], vertices[1], vertices[2]); + if !area.is_finite() || area.abs() <= 1.0e-12 { + return None; + } + let orientation = area.signum(); + let delta = [second[0] - first[0], second[1] - first[1]]; + let mut lower: f64 = 0.0; + let mut upper: f64 = 1.0; + for edge in 0..3 { + let edge_first = vertices[edge]; + let edge_second = vertices[(edge + 1) % 3]; + let start_distance = orientation * cross2(edge_first, edge_second, first); + let end = [first[0] + delta[0], first[1] + delta[1]]; + let end_distance = orientation * cross2(edge_first, edge_second, end); + let slope = end_distance - start_distance; + if slope.abs() <= 1.0e-15 { + if start_distance < -LINE_EPSILON { + return None; + } + continue; + } + // Tolerance decides whether a nearly outside interval is accepted; + // the actual boundary sample remains on the geometric edge (distance 0). + let crossing = -start_distance / slope; + if slope > 0.0 { + lower = lower.max(crossing); + } else { + upper = upper.min(crossing); + } + if lower > upper { + return None; + } + } + (upper >= 0.0 && lower <= 1.0).then_some((lower.clamp(0.0, 1.0), upper.clamp(0.0, 1.0))) +} + +fn lift_to_face(point: [f64; 2], triangle: [[f32; 3]; 3]) -> Option<[f32; 3]> { + let vertices = triangle.map(|vertex| vertex.map(f64::from)); + let denominator = cross2( + [vertices[0][0], vertices[0][1]], + [vertices[1][0], vertices[1][1]], + [vertices[2][0], vertices[2][1]], + ); + if denominator.abs() <= 1.0e-12 { + return None; + } + let weight_b = cross2( + [vertices[0][0], vertices[0][1]], + point, + [vertices[2][0], vertices[2][1]], + ) / denominator; + let weight_c = cross2( + [vertices[0][0], vertices[0][1]], + [vertices[1][0], vertices[1][1]], + point, + ) / denominator; + let weight_a = 1.0 - weight_b - weight_c; + let z = weight_a * vertices[0][2] + weight_b * vertices[1][2] + weight_c * vertices[2][2]; + let result = [point[0] as f32, point[1] as f32, z as f32]; + result + .iter() + .all(|value| value.is_finite()) + .then_some(result) +} + +fn crossings_as_ring( + crossings: &[[f32; 3]], + closed: bool, +) -> Result<Vec<[f32; 3]>, BasementFoundationError> { + let length = crossings.len().saturating_sub(usize::from(closed)); + let ring = crossings.iter().take(length).copied().collect::<Vec<_>>(); + (ring.len() >= 3) + .then_some(ring) + .ok_or(BasementFoundationError::InvalidContour) +} + +fn inner_ring_without_closure(ring: &[[f32; 3]]) -> Vec<[f32; 3]> { + let mut result = ring.to_vec(); + if result.len() > 1 && same_xy(result[0], *result.last().expect("nonempty")) { + result.pop(); + } + result +} + +fn triangulate_annulus( + outer: &[[f32; 3]], + inner: Vec<[f32; 3]>, +) -> Result<Vec<[[f32; 3]; 3]>, BasementFoundationError> { + let mut outer = remove_consecutive_duplicates(outer); + let mut inner = remove_consecutive_duplicates(&inner); + if outer.len() < 3 || inner.len() < 3 || !nested_rings_3d(&outer, &inner) { + return Err(BasementFoundationError::InvalidContour); + } + if signed_area_3d(&outer) < 0.0 { + outer.reverse(); + } + if signed_area_3d(&inner) > 0.0 { + inner.reverse(); + } + let polygon = bridge_hole(&outer, &inner)?; + let output = + legalize_constrained_edges(ear_clip_weakly_simple_polygon(&polygon)?, &outer, &inner)?; + if output.iter().any(|triangle| { + !triangle.iter().flatten().all(|value| value.is_finite()) + || cross2(xy(triangle[0]), xy(triangle[1]), xy(triangle[2])).abs() <= 1.0e-9 + }) { + return Err(BasementFoundationError::InvalidContour); + } + Ok(output) +} + +fn bridge_hole( + outer: &[[f32; 3]], + inner: &[[f32; 3]], +) -> Result<Vec<[f32; 3]>, BasementFoundationError> { + let hole_index = (0..inner.len()) + .max_by(|left, right| { + inner[*left][0] + .total_cmp(&inner[*right][0]) + .then_with(|| inner[*right][1].total_cmp(&inner[*left][1])) + }) + .ok_or(BasementFoundationError::InvalidContour)?; + let hole = inner[hole_index]; + let mut candidates = (0..outer.len()) + .map(|index| (distance_squared(xy(hole), xy(outer[index])), index)) + .collect::<Vec<_>>(); + candidates.sort_by(|left, right| { + left.0 + .total_cmp(&right.0) + .then_with(|| outer[right.1][0].total_cmp(&outer[left.1][0])) + }); + let mut bridged_outer = None; + for (_, candidate_index) in candidates { + let candidate = outer[candidate_index]; + if visible_bridge(xy(hole), xy(candidate), outer, inner) { + bridged_outer = Some((outer.to_vec(), candidate_index)); + break; + } + } + let (outer, outer_index) = bridged_outer.ok_or(BasementFoundationError::InvalidContour)?; + let mut polygon = Vec::with_capacity(outer.len() + inner.len() + 2); + for step in 0..outer.len() { + let index = (outer_index + step) % outer.len(); + polygon.push(outer[index]); + if step == 0 { + polygon.push(inner[hole_index]); + for hole_step in 1..inner.len() { + polygon.push(inner[(hole_index + hole_step) % inner.len()]); + } + polygon.push(inner[hole_index]); + polygon.push(outer[outer_index]); + } + } + Ok(polygon) +} + +fn visible_bridge( + hole: [f64; 2], + outer: [f64; 2], + outer_ring: &[[f32; 3]], + inner_ring: &[[f32; 3]], +) -> bool { + let midpoint = [(hole[0] + outer[0]) * 0.5, (hole[1] + outer[1]) * 0.5]; + let outer_xy = outer_ring.iter().copied().map(xy).collect::<Vec<_>>(); + let inner_xy = inner_ring.iter().copied().map(xy).collect::<Vec<_>>(); + if !point_in_ring(midpoint, &outer_xy) || point_in_ring(midpoint, &inner_xy) { + return false; + } + if (0..outer_xy.len()).any(|index| { + segment_crosses_open_edge( + hole, + outer, + outer_xy[index], + outer_xy[(index + 1) % outer_xy.len()], + ) + }) { + return false; + } + !(0..inner_xy.len()).any(|index| { + segment_crosses_open_edge( + hole, + outer, + inner_xy[index], + inner_xy[(index + 1) % inner_xy.len()], + ) + }) +} + +fn segment_crosses_open_edge( + first: [f64; 2], + second: [f64; 2], + edge_first: [f64; 2], + edge_second: [f64; 2], +) -> bool { + if same_point(first, edge_first) + || same_point(first, edge_second) + || same_point(second, edge_first) + || same_point(second, edge_second) + { + return false; + } + segments_intersect(first, second, edge_first, edge_second) +} + +fn same_point(first: [f64; 2], second: [f64; 2]) -> bool { + distance_squared(first, second) <= POINT_EPSILON_SQUARED +} + +fn ear_clip_weakly_simple_polygon( + polygon: &[[f32; 3]], +) -> Result<Vec<[[f32; 3]; 3]>, BasementFoundationError> { + let mut remaining = (0..polygon.len()).collect::<Vec<_>>(); + let mut triangles = Vec::with_capacity(polygon.len().saturating_sub(2)); + while remaining.len() > 3 { + let mut ear = None; + for slot in 0..remaining.len() { + let previous = remaining[(slot + remaining.len() - 1) % remaining.len()]; + let current = remaining[slot]; + let next = remaining[(slot + 1) % remaining.len()]; + let a = xy(polygon[previous]); + let b = xy(polygon[current]); + let c = xy(polygon[next]); + if cross2(a, b, c) <= 1.0e-9 || !diagonal_is_clear(previous, next, &remaining, polygon) + { + continue; + } + let contains_point = remaining.iter().copied().any(|candidate| { + candidate != previous + && candidate != current + && candidate != next + && !same_xy(polygon[candidate], polygon[previous]) + && !same_xy(polygon[candidate], polygon[current]) + && !same_xy(polygon[candidate], polygon[next]) + && point_in_or_on_triangle(xy(polygon[candidate]), a, b, c) + }); + if !contains_point { + ear = Some((slot, [polygon[previous], polygon[current], polygon[next]])); + break; + } + } + if let Some((slot, triangle)) = ear { + triangles.push(triangle); + remaining.remove(slot); + } else { + return Err(BasementFoundationError::InvalidContour); + } + } + if remaining.len() == 3 { + let triangle = [ + polygon[remaining[0]], + polygon[remaining[1]], + polygon[remaining[2]], + ]; + if cross2(xy(triangle[0]), xy(triangle[1]), xy(triangle[2])) > 1.0e-9 { + triangles.push(triangle); + } + } + (!triangles.is_empty()) + .then_some(triangles) + .ok_or(BasementFoundationError::InvalidContour) +} + +fn diagonal_is_clear( + first_index: usize, + second_index: usize, + polygon_indices: &[usize], + polygon: &[[f32; 3]], +) -> bool { + let first = xy(polygon[first_index]); + let second = xy(polygon[second_index]); + for edge in 0..polygon_indices.len() { + let edge_first_index = polygon_indices[edge]; + let edge_second_index = polygon_indices[(edge + 1) % polygon_indices.len()]; + if edge_first_index == first_index + || edge_second_index == first_index + || edge_first_index == second_index + || edge_second_index == second_index + { + continue; + } + if segment_crosses_open_edge( + first, + second, + xy(polygon[edge_first_index]), + xy(polygon[edge_second_index]), + ) { + return false; + } + } + true +} + +fn point_in_or_on_triangle(point: [f64; 2], a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> bool { + let epsilon = 1.0e-9; + cross2(a, b, point) >= -epsilon + && cross2(b, c, point) >= -epsilon + && cross2(c, a, point) >= -epsilon +} + +fn nested_rings(outer: &[[f64; 2]], inner: &[[f64; 2]]) -> bool { + if !simple_ring(outer) || !simple_ring(inner) { + return false; + } + if rings_intersect(outer, inner) { + return false; + } + point_in_ring(inner[0], outer) +} + +fn nested_rings_3d(outer: &[[f32; 3]], inner: &[[f32; 3]]) -> bool { + let outer_xy = outer.iter().copied().map(xy).collect::<Vec<_>>(); + let inner_xy = inner.iter().copied().map(xy).collect::<Vec<_>>(); + nested_rings(&outer_xy, &inner_xy) +} + +fn remove_consecutive_duplicates(ring: &[[f32; 3]]) -> Vec<[f32; 3]> { + let mut result = Vec::with_capacity(ring.len()); + for point in ring { + if result + .last() + .is_none_or(|previous| !same_xy(*previous, *point)) + { + result.push(*point); + } + } + if result.len() > 1 && same_xy(result[0], *result.last().expect("nonempty")) { + result.pop(); + } + result +} + +fn simple_ring(ring: &[[f64; 2]]) -> bool { + if ring.len() < 3 || ring.iter().flatten().any(|value| !value.is_finite()) { + return false; + } + let area = ring_area(ring); + if !area.is_finite() || area.abs() <= 1.0e-10 { + return false; + } + for first in 0..ring.len() { + let first_next = (first + 1) % ring.len(); + if distance_squared(ring[first], ring[first_next]) <= POINT_EPSILON_SQUARED { + return false; + } + for second in (first + 1)..ring.len() { + let second_next = (second + 1) % ring.len(); + if first == second || first_next == second || second_next == first { + continue; + } + if segments_intersect( + ring[first], + ring[first_next], + ring[second], + ring[second_next], + ) { + return false; + } + } + } + true +} + +fn rings_intersect(first: &[[f64; 2]], second: &[[f64; 2]]) -> bool { + (0..first.len()).any(|first_index| { + (0..second.len()).any(|second_index| { + segments_intersect( + first[first_index], + first[(first_index + 1) % first.len()], + second[second_index], + second[(second_index + 1) % second.len()], + ) + }) + }) +} + +fn rings_overlap(first: &[[f64; 2]], second: &[[f64; 2]]) -> bool { + rings_intersect(first, second) + || point_in_ring(first[0], second) + || point_in_ring(second[0], first) +} + +fn point_in_ring(point: [f64; 2], ring: &[[f64; 2]]) -> bool { + let mut inside = false; + for index in 0..ring.len() { + let first = ring[index]; + let second = ring[(index + 1) % ring.len()]; + if point_on_segment(point, first, second) { + return true; + } + if (first[1] > point[1]) != (second[1] > point[1]) { + let intersection_x = + first[0] + (point[1] - first[1]) * (second[0] - first[0]) / (second[1] - first[1]); + if point[0] < intersection_x { + inside = !inside; + } + } + } + inside +} + +fn point_on_segment(point: [f64; 2], first: [f64; 2], second: [f64; 2]) -> bool { + let dx = second[0] - first[0]; + let dy = second[1] - first[1]; + let length = (dx * dx + dy * dy).sqrt(); + cross2(first, second, point).abs() <= LINE_EPSILON * length + && point[0] >= first[0].min(second[0]) - LINE_EPSILON + && point[0] <= first[0].max(second[0]) + LINE_EPSILON + && point[1] >= first[1].min(second[1]) - LINE_EPSILON + && point[1] <= first[1].max(second[1]) + LINE_EPSILON +} + +fn segments_intersect(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool { + let ab_c = cross2(a, b, c); + let ab_d = cross2(a, b, d); + let cd_a = cross2(c, d, a); + let cd_b = cross2(c, d, b); + let epsilon = 1.0e-8; + (ab_c.abs() <= epsilon && on_segment(c, a, b, epsilon)) + || (ab_d.abs() <= epsilon && on_segment(d, a, b, epsilon)) + || (cd_a.abs() <= epsilon && on_segment(a, c, d, epsilon)) + || (cd_b.abs() <= epsilon && on_segment(b, c, d, epsilon)) + || (ab_c * ab_d < 0.0 && cd_a * cd_b < 0.0) +} + +fn on_segment(point: [f64; 2], first: [f64; 2], second: [f64; 2], epsilon: f64) -> bool { + point[0] >= first[0].min(second[0]) - epsilon + && point[0] <= first[0].max(second[0]) + epsilon + && point[1] >= first[1].min(second[1]) - epsilon + && point[1] <= first[1].max(second[1]) + epsilon +} + +fn ring_area(ring: &[[f64; 2]]) -> f64 { + (0..ring.len()) + .map(|index| { + let first = ring[index]; + let second = ring[(index + 1) % ring.len()]; + first[0] * second[1] - second[0] * first[1] + }) + .sum::<f64>() + * 0.5 +} + +fn signed_area_3d(ring: &[[f32; 3]]) -> f64 { + ring_area(&ring.iter().copied().map(xy).collect::<Vec<_>>()) +} + +fn xy(position: [f32; 3]) -> [f64; 2] { + [f64::from(position[0]), f64::from(position[1])] +} + +fn same_xy(first: [f32; 3], second: [f32; 3]) -> bool { + distance_squared(xy(first), xy(second)) <= POINT_EPSILON_SQUARED +} + +fn cross2(first: [f64; 2], second: [f64; 2], third: [f64; 2]) -> f64 { + (second[0] - first[0]) * (third[1] - first[1]) - (second[1] - first[1]) * (third[0] - first[0]) +} + +fn distance_squared(first: [f64; 2], second: [f64; 2]) -> f64 { + let dx = first[0] - second[0]; + let dy = first[1] - second[1]; + dx * dx + dy * dy +} + +pub(crate) fn basement_surface_mask(positions: [[f32; 3]; 3]) -> FullSurfaceMask { + let first = positions[1]; + let origin = positions[0]; + let second = positions[2]; + let normal_z = (first[0] - origin[0]) * (second[1] - origin[1]) + - (first[1] - origin[1]) * (second[0] - origin[0]); + FullSurfaceMask(if normal_z < 0.0 { 0x0300 } else { 0x8300 }) +} + +fn legalize_constrained_edges( + mut triangles: Vec<[[f32; 3]; 3]>, + outer: &[[f32; 3]], + inner: &[[f32; 3]], +) -> Result<Vec<[[f32; 3]; 3]>, BasementFoundationError> { + let max_flips = triangles + .len() + .saturating_mul(triangles.len()) + .saturating_mul(4) + .max(1); + for _ in 0..max_flips { + let mut flipped = false; + 'pairs: for first in 0..triangles.len() { + for second in (first + 1)..triangles.len() { + let Some((a, b, c, d)) = shared_edge(triangles[first], triangles[second]) else { + continue; + }; + if ring_has_edge(outer, a, b) || ring_has_edge(inner, a, b) { + continue; + } + if !convex_quadrilateral(a, b, c, d) || in_circle(a, b, c, d) <= 1.0e-8 { + continue; + } + let first_triangle = orient_ccw([c, d, b]); + let second_triangle = orient_ccw([d, c, a]); + if cross2( + xy(first_triangle[0]), + xy(first_triangle[1]), + xy(first_triangle[2]), + ) <= 1.0e-9 + || cross2( + xy(second_triangle[0]), + xy(second_triangle[1]), + xy(second_triangle[2]), + ) <= 1.0e-9 + { + return Err(BasementFoundationError::InvalidContour); + } + triangles[first] = first_triangle; + triangles[second] = second_triangle; + flipped = true; + break 'pairs; + } + } + if !flipped { + return Ok(triangles); + } + } + Err(BasementFoundationError::InvalidContour) +} + +fn shared_edge( + first: [[f32; 3]; 3], + second: [[f32; 3]; 3], +) -> Option<([f32; 3], [f32; 3], [f32; 3], [f32; 3])> { + for edge in 0..3 { + let a = first[edge]; + let b = first[(edge + 1) % 3]; + if let Some(opposite) = second + .iter() + .copied() + .find(|point| !same_xy(*point, a) && !same_xy(*point, b)) + { + let has_a = second.iter().any(|point| same_xy(*point, a)); + let has_b = second.iter().any(|point| same_xy(*point, b)); + if has_a && has_b { + let first_opposite = first[(edge + 2) % 3]; + return Some((a, b, first_opposite, opposite)); + } + } + } + None +} + +fn ring_has_edge(ring: &[[f32; 3]], a: [f32; 3], b: [f32; 3]) -> bool { + (0..ring.len()).any(|index| { + let first = ring[index]; + let second = ring[(index + 1) % ring.len()]; + (same_xy(first, a) && same_xy(second, b)) || (same_xy(first, b) && same_xy(second, a)) + }) +} + +fn convex_quadrilateral(a: [f32; 3], b: [f32; 3], c: [f32; 3], d: [f32; 3]) -> bool { + let epsilon = 1.0e-9; + let c_side = cross2(xy(a), xy(b), xy(c)); + let d_side = cross2(xy(a), xy(b), xy(d)); + let a_side = cross2(xy(c), xy(d), xy(a)); + let b_side = cross2(xy(c), xy(d), xy(b)); + c_side * d_side < -epsilon && a_side * b_side < -epsilon +} + +fn in_circle(a: [f32; 3], b: [f32; 3], c: [f32; 3], d: [f32; 3]) -> f64 { + let a = [ + f64::from(a[0]) - f64::from(d[0]), + f64::from(a[1]) - f64::from(d[1]), + ]; + let b = [ + f64::from(b[0]) - f64::from(d[0]), + f64::from(b[1]) - f64::from(d[1]), + ]; + let c = [ + f64::from(c[0]) - f64::from(d[0]), + f64::from(c[1]) - f64::from(d[1]), + ]; + (a[0] * a[0] + a[1] * a[1]) * (b[0] * c[1] - b[1] * c[0]) + - (b[0] * b[0] + b[1] * b[1]) * (a[0] * c[1] - a[1] * c[0]) + + (c[0] * c[0] + c[1] * c[1]) * (a[0] * b[1] - a[1] * b[0]) +} + +fn orient_ccw(mut triangle: [[f32; 3]; 3]) -> [[f32; 3]; 3] { + if cross2(xy(triangle[0]), xy(triangle[1]), xy(triangle[2])) < 0.0 { + triangle.swap(1, 2); + } + triangle +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_terrain_format::{TerrainFace28, TerrainSlotTable}; + + #[test] + fn foundation_samples_lod0_planes_and_flattens_or_preserves_authored_inner_z() { + let mesh = two_lod_mesh(); + let outer = [[0.4, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.4, 0.0]]; + let inner = [ + [1.0, 1.0, 10.0], + [3.0, 1.0, 11.0], + [3.0, 3.0, 12.0], + [1.0, 3.0, 13.0], + [1.0, 1.0, 10.0], + ]; + let (open_outer, _) = normalize_ring(&outer).expect("outer ring"); + let snapped = normalize_outer_points(&open_outer, &[0, 1, 2], &mesh) + .expect("outer points lie within LOD0"); + assert_eq!(snapped.first(), Some(&[0.0, 0.0])); + assert!(!snapped.contains(&[2.0, 0.0])); + assert!(native_segment_inserts_vertex( + [0.0, 0.0], + [4.0, 0.0], + [3.96875, 0.4991, 12.0] + )); + assert!(!native_segment_inserts_vertex( + [0.0, 0.0], + [4.0, 0.0], + [2.0, 0.0, 12.0] + )); + let terrain = [ + ( + 0, + mesh.faces[0].flags, + terrain_face_positions(&mesh, 0).unwrap(), + ), + ( + 1, + mesh.faces[1].flags, + terrain_face_positions(&mesh, 1).unwrap(), + ), + ( + 2, + mesh.faces[2].flags, + terrain_face_positions(&mesh, 2).unwrap(), + ), + ]; + let crossings = contour_crossings(&snapped, &terrain).expect("LOD0 crossings"); + assert!(crossings.len() > 4); + assert_eq!(crossings.first().unwrap()[2], 0.0); + assert!(crossings + .iter() + .any(|crossing| same_xy(*crossing, [2.0, 0.0, 2.0]))); + let expected_mean = + crossings.iter().map(|crossing| crossing[2]).sum::<f32>() / crossings.len() as f32; + + let flat = build_basement_foundations( + &mesh, + &[BasementFootprint { + outer: &outer, + inner: &inner, + flatten_inner_to_mean_outer_z: true, + }], + ) + .expect("flat foundation"); + let placement = flat.placements[0]; + assert!((placement.placement_delta - (10.0 - expected_mean)).abs() < 1.0e-6); + let flat_band = flat + .fragments + .iter() + .filter(|fragment| fragment.source_face.is_none()) + .collect::<Vec<_>>(); + assert!(flat + .fragments + .iter() + .filter_map(|fragment| fragment.source_face) + .all(|face| face < 3)); + assert!(!flat_band.is_empty()); + assert!(flat_band + .iter() + .all(|fragment| fragment.surface_mask == FullSurfaceMask(0x8300))); + for point in &inner[..4] { + assert!(flat_band + .iter() + .flat_map(|fragment| fragment.positions) + .any(|vertex| { + same_xy(vertex, *point) && (vertex[2] - expected_mean).abs() < 1.0e-6 + })); + } + let flat_area = flat_band + .iter() + .map(|fragment| { + let [a, b, c] = fragment.positions; + cross2(xy(a), xy(b), xy(c)).abs() * 0.5 + }) + .sum::<f64>(); + assert!((flat_area - 12.0).abs() < 1.0e-5); + + let authored = build_basement_foundations( + &mesh, + &[BasementFootprint { + outer: &outer, + inner: &inner, + flatten_inner_to_mean_outer_z: false, + }], + ) + .expect("authored-height foundation"); + let authored_band = authored + .fragments + .iter() + .filter(|fragment| fragment.source_face.is_none()) + .collect::<Vec<_>>(); + for point in &inner[..4] { + assert!(authored_band + .iter() + .flat_map(|fragment| fragment.positions) + .any(|vertex| { + same_xy(vertex, *point) && (vertex[2] - point[2]).abs() < 1.0e-6 + })); + } + } + + #[test] + fn foundation_rejects_outer_contours_outside_or_across_uncovered_terrain() { + let mesh = two_lod_mesh(); + let outside = [[5.0, 1.0], [5.5, 1.0], [5.0, 1.5]]; + let inner = [[5.1, 1.1, 0.0], [5.3, 1.1, 0.0], [5.1, 1.3, 0.0]]; + assert_eq!( + build_basement_foundations( + &mesh, + &[BasementFootprint { + outer: &outside, + inner: &inner, + flatten_inner_to_mean_outer_z: true, + }], + ), + Err(BasementFoundationError::OuterPointOutsideLod0 { point: 0 }) + ); + + let mut islands = two_lod_mesh(); + islands.positions = vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [2.0, 2.0, 0.0], + [3.0, 2.0, 0.0], + [2.0, 3.0, 0.0], + ]; + islands.faces.truncate(2); + islands.faces[0].vertices = [0, 1, 2]; + islands.faces[1].vertices = [3, 4, 5]; + islands.slots.slots_raw[0][2..4].copy_from_slice(&2_u16.to_le_bytes()); + islands.slots.slots_raw[1][0..2].copy_from_slice(&2_u16.to_le_bytes()); + islands.slots.slots_raw[1][2..4].copy_from_slice(&0_u16.to_le_bytes()); + let outer = [[0.1, 0.1], [0.7, 0.1], [2.1, 2.1]]; + let inner = [[0.2, 0.2, 0.0], [0.4, 0.2, 0.0], [0.2, 0.4, 0.0]]; + assert_eq!( + build_basement_foundations( + &islands, + &[BasementFootprint { + outer: &outer, + inner: &inner, + flatten_inner_to_mean_outer_z: true, + }], + ), + Err(BasementFoundationError::UncoveredOuterSegment { segment: 1 }) + ); + } + + #[test] + fn shared_diagonal_crossing_is_exact_and_counted_once() { + let first = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 1.0]]; + let second = [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [0.0, 1.0, 2.0]]; + let edge_start = [0.8, 0.5]; + let edge_end = [0.5, 0.8]; + let first_interval = + segment_triangle_interval(edge_start, edge_end, first).expect("first face interval"); + let second_interval = + segment_triangle_interval(edge_start, edge_end, second).expect("second face interval"); + assert!((first_interval.1 - 0.5).abs() < 1.0e-9); + assert!((second_interval.0 - 0.5).abs() < 1.0e-9); + + let ring = [[0.5, 0.2], [0.8, 0.5], [0.5, 0.8], [0.2, 0.5]]; + let crossings = contour_crossings( + &ring, + &[ + (0, FullSurfaceMask(1), first), + (1, FullSurfaceMask(2), second), + ], + ) + .expect("shared-edge crossings"); + let shared = crossings + .iter() + .filter(|crossing| { + distance_squared( + [f64::from(crossing[0]), f64::from(crossing[1])], + [0.65, 0.65], + ) <= POINT_EPSILON_SQUARED + }) + .collect::<Vec<_>>(); + assert_eq!( + shared.len(), + 1, + "shared-edge crossing duplicated: {shared:?}" + ); + assert!((shared[0][2] - 0.65).abs() < 1.0e-6); + } + + #[test] + fn annulus_preserves_collinear_outer_height_breakpoints_and_is_constrained_delaunay() { + let outer = [ + [0.0, 0.0, 0.0], + [2.0, 0.0, 3.0], + [4.0, 0.0, 10.0], + [4.0, 4.0, 12.0], + [0.0, 4.0, 0.0], + ]; + let inner = vec![ + [1.0, 1.0, 5.0], + [3.0, 1.0, 5.0], + [3.0, 3.0, 5.0], + [1.0, 3.0, 5.0], + ]; + let triangles = triangulate_annulus(&outer, inner.clone()).expect("annulus"); + for edge in outer.windows(2) { + assert!( + triangles + .iter() + .any(|triangle| has_xy_edge(*triangle, edge[0], edge[1])), + "missing outer boundary segment {:?} -> {:?}", + edge[0], + edge[1] + ); + } + for point in outer { + assert!( + triangles + .iter() + .flat_map(|triangle| triangle.iter()) + .any(|vertex| { + same_xy(*vertex, point) && (vertex[2] - point[2]).abs() < 1.0e-6 + }), + "lost outer breakpoint {point:?}" + ); + } + assert_locally_delaunay(&triangles, &outer, &inner); + } + + #[test] + fn constrained_annulus_handles_concave_rings_with_different_vertex_counts() { + let outer = [ + [0.0, 0.0, 1.0], + [10.0, 0.0, 2.0], + [10.0, 3.0, 3.0], + [4.0, 3.0, 4.0], + [4.0, 10.0, 5.0], + [0.0, 10.0, 6.0], + ]; + let inner = vec![ + [1.0, 1.0, 3.0], + [3.0, 1.0, 3.0], + [3.0, 1.8, 3.0], + [2.2, 1.8, 3.0], + [2.2, 2.4, 3.0], + [3.0, 2.4, 3.0], + [3.0, 3.0, 3.0], + [1.0, 3.0, 3.0], + ]; + let band = triangulate_annulus(&outer, inner.clone()).expect("constrained annulus"); + let area = band + .iter() + .map(|triangle| cross2(xy(triangle[0]), xy(triangle[1]), xy(triangle[2])).abs() * 0.5) + .sum::<f64>(); + let expected = ring_area(&outer.iter().copied().map(xy).collect::<Vec<_>>()).abs() + - ring_area(&inner.iter().copied().map(xy).collect::<Vec<_>>()).abs(); + assert!( + (area - expected).abs() < 1.0e-5, + "area {area}, expected {expected}" + ); + + let outer_xy = outer.iter().copied().map(xy).collect::<Vec<_>>(); + let inner_xy = inner.iter().copied().map(xy).collect::<Vec<_>>(); + for triangle in &band { + let centroid = triangle_centroid(*triangle); + assert!(point_in_ring(centroid, &outer_xy)); + assert!(!point_in_ring(centroid, &inner_xy)); + } + for first in 0..band.len() { + for second in (first + 1)..band.len() { + assert!(!positive_area_overlap(band[first], band[second])); + } + } + assert_locally_delaunay(&band, &outer, &inner); + } + + #[test] + fn multiple_disjoint_footprints_clip_canonical_outside_once() { + let mesh = two_lod_mesh(); + let first_outer = [[0.6, 0.6], [1.7, 0.6], [1.7, 1.7], [0.6, 1.7]]; + let first_inner = [ + [0.9, 0.9, 5.0], + [1.4, 0.9, 5.0], + [1.4, 1.4, 5.0], + [0.9, 1.4, 5.0], + ]; + let second_outer = [[2.3, 2.3], [3.4, 2.3], [3.4, 3.4], [2.3, 3.4]]; + let second_inner = [ + [2.6, 2.6, 7.0], + [3.1, 2.6, 7.0], + [3.1, 3.1, 7.0], + [2.6, 3.1, 7.0], + ]; + let output = build_basement_foundations( + &mesh, + &[ + BasementFootprint { + outer: &first_outer, + inner: &first_inner, + flatten_inner_to_mean_outer_z: true, + }, + BasementFootprint { + outer: &second_outer, + inner: &second_inner, + flatten_inner_to_mean_outer_z: true, + }, + ], + ) + .expect("two disjoint foundations"); + assert_eq!(output.placements.len(), 2); + let terrain = output + .fragments + .iter() + .filter(|fragment| fragment.source_face.is_some()) + .collect::<Vec<_>>(); + let bands = output + .fragments + .iter() + .filter(|fragment| fragment.source_face.is_none()) + .collect::<Vec<_>>(); + assert!(!terrain.is_empty()); + assert!(!bands.is_empty()); + for fragment in &terrain { + let centroid = triangle_centroid(fragment.positions); + assert!(!point_strictly_in_ring(centroid, &ring_f64(&first_outer))); + assert!(!point_strictly_in_ring(centroid, &ring_f64(&second_outer))); + } + for first in 0..terrain.len() { + for second in (first + 1)..terrain.len() { + assert!(!positive_area_overlap( + terrain[first].positions, + terrain[second].positions + )); + } + } + for band in bands { + let centroid = triangle_centroid(band.positions); + assert!( + point_in_ring(centroid, &ring_f64(&first_outer)) + || point_in_ring(centroid, &ring_f64(&second_outer)) + ); + assert!(!point_in_ring( + centroid, + &ring_f64(&first_inner.iter().map(|p| [p[0], p[1]]).collect::<Vec<_>>()) + )); + assert!(!point_in_ring( + centroid, + &ring_f64( + &second_inner + .iter() + .map(|p| [p[0], p[1]]) + .collect::<Vec<_>>() + ) + )); + } + } + + #[test] + fn overlapping_footprints_are_explicitly_rejected() { + let mesh = two_lod_mesh(); + let outer_a = [[0.5, 0.5], [2.5, 0.5], [2.5, 2.5], [0.5, 2.5]]; + let inner_a = [ + [1.0, 1.0, 3.0], + [2.0, 1.0, 3.0], + [2.0, 2.0, 3.0], + [1.0, 2.0, 3.0], + ]; + let outer_b = [[1.5, 1.5], [3.5, 1.5], [3.5, 3.5], [1.5, 3.5]]; + let inner_b = [ + [2.0, 2.0, 3.0], + [3.0, 2.0, 3.0], + [3.0, 3.0, 3.0], + [2.0, 3.0, 3.0], + ]; + assert_eq!( + build_basement_foundations( + &mesh, + &[ + BasementFootprint { + outer: &outer_a, + inner: &inner_a, + flatten_inner_to_mean_outer_z: true + }, + BasementFootprint { + outer: &outer_b, + inner: &inner_b, + flatten_inner_to_mean_outer_z: true + }, + ], + ), + Err(BasementFoundationError::OverlappingFootprints) + ); + } + + #[test] + fn generated_flags_follow_native_normal_z_branch() { + let positive = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + let negative = [positive[0], positive[2], positive[1]]; + assert_eq!(basement_surface_mask(positive), FullSurfaceMask(0x8300)); + assert_eq!(basement_surface_mask(negative), FullSurfaceMask(0x0300)); + } + + #[test] + fn foundation_rejects_inner_ring_that_is_not_nested() { + let mesh = two_lod_mesh(); + let outer = [[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]]; + let inner = [ + [3.0, 1.0, 0.0], + [5.0, 1.0, 0.0], + [5.0, 3.0, 0.0], + [3.0, 3.0, 0.0], + ]; + assert_eq!( + build_basement_foundations( + &mesh, + &[BasementFootprint { + outer: &outer, + inner: &inner, + flatten_inner_to_mean_outer_z: true + }], + ), + Err(BasementFoundationError::InvalidContour) + ); + } + + fn assert_locally_delaunay( + triangles: &[[[f32; 3]; 3]], + outer: &[[f32; 3]], + inner: &[[f32; 3]], + ) { + for first in 0..triangles.len() { + for second in (first + 1)..triangles.len() { + let Some((a, b, c, d)) = shared_edge(triangles[first], triangles[second]) else { + continue; + }; + if ring_has_edge(outer, a, b) + || ring_has_edge(inner, a, b) + || !convex_quadrilateral(a, b, c, d) + { + continue; + } + assert!( + in_circle(a, b, c, d) <= 1.0e-8, + "illegal diagonal {a:?}->{b:?}" + ); + } + } + } + + fn has_xy_edge(triangle: [[f32; 3]; 3], first: [f32; 3], second: [f32; 3]) -> bool { + (0..3).any(|index| { + (same_xy(triangle[index], first) && same_xy(triangle[(index + 1) % 3], second)) + || (same_xy(triangle[index], second) && same_xy(triangle[(index + 1) % 3], first)) + }) + } + + fn triangle_centroid(triangle: [[f32; 3]; 3]) -> [f64; 2] { + [ + triangle + .iter() + .map(|point| f64::from(point[0])) + .sum::<f64>() + / 3.0, + triangle + .iter() + .map(|point| f64::from(point[1])) + .sum::<f64>() + / 3.0, + ] + } + + fn point_strictly_in_ring(point: [f64; 2], ring: &[[f64; 2]]) -> bool { + !((0..ring.len()) + .any(|index| point_on_segment(point, ring[index], ring[(index + 1) % ring.len()]))) + && point_in_ring(point, ring) + } + + fn ring_f64(ring: &[[f32; 2]]) -> Vec<[f64; 2]> { + ring.iter() + .map(|point| [f64::from(point[0]), f64::from(point[1])]) + .collect() + } + + fn two_lod_mesh() -> LandMeshDocument { + let positions = vec![ + [0.0, 0.0, 0.0], + [2.0, 0.0, 2.0], + [4.0, 0.0, 4.0], + [0.0, 4.0, 4.0], + [4.0, 4.0, 8.0], + [0.0, 0.0, 100.0], + [4.0, 0.0, 100.0], + [0.0, 4.0, 100.0], + ]; + let face = |flags, vertices| TerrainFace28 { + flags: FullSurfaceMask(flags), + material_tag: 0, + aux_tag: 0, + vertices, + neighbors: [None; 3], + tail_raw: [0; 8], + raw: [0; 28], + }; + let faces = vec![ + face(1, [0, 1, 3]), + face(2, [1, 4, 3]), + face(4, [1, 2, 4]), + face(8, [5, 6, 7]), + ]; + let mut nodes_raw = vec![u8::MAX; 38]; + nodes_raw[8..10].copy_from_slice(&0_u16.to_le_bytes()); + nodes_raw[10..12].copy_from_slice(&1_u16.to_le_bytes()); + let mut slots_raw = vec![[0_u8; 68]; 2]; + slots_raw[0][2..4].copy_from_slice(&3_u16.to_le_bytes()); + slots_raw[1][0..2].copy_from_slice(&3_u16.to_le_bytes()); + slots_raw[1][2..4].copy_from_slice(&1_u16.to_le_bytes()); + LandMeshDocument { + streams: Vec::new(), + nodes_raw, + slots: TerrainSlotTable { + header_raw: Vec::new(), + slots_raw, + }, + positions, + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces, + } + } + + fn positive_area_overlap(first: [[f32; 3]; 3], second: [[f32; 3]; 3]) -> bool { + for triangle in [first, second] { + for edge in 0..3 { + let a = xy(triangle[edge]); + let b = xy(triangle[(edge + 1) % 3]); + let axis = [b[1] - a[1], a[0] - b[0]]; + let project = |vertices: [[f32; 3]; 3]| { + vertices.map(|vertex| { + let point = xy(vertex); + point[0] * axis[0] + point[1] * axis[1] + }) + }; + let first_projection = project(first); + let second_projection = project(second); + let first_min = first_projection + .iter() + .copied() + .fold(f64::INFINITY, f64::min); + let first_max = first_projection + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let second_min = second_projection + .iter() + .copied() + .fold(f64::INFINITY, f64::min); + let second_max = second_projection + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + if first_max <= second_min + 1.0e-8 || second_max <= first_min + 1.0e-8 { + return false; + } + } + } + true + } +} diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index a014b61..db55506 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -4,6 +4,15 @@ use fparkan_terrain_format::{LandMapDocument, LandMeshDocument}; use std::collections::VecDeque; +mod contour; +mod foundation; + +use foundation::basement_surface_mask; +pub use foundation::{ + build_basement_foundations, BasementFootprint, BasementFoundation, BasementFoundationError, + BasementFoundationFragment, BasementPlacement, +}; + /// Terrain material-selector contract preserved from the decoded map mesh. /// /// Applications access this semantic terrain API through this terrain crate @@ -21,6 +30,7 @@ pub struct TerrainWorld { surfaces: Vec<RuntimeTriangle>, surface_bvh: SurfaceBvh, source_mesh: Option<LandMeshDocument>, + basement_foundation: Option<BasementFoundation>, } /// Surface hit. @@ -91,6 +101,13 @@ pub enum TerrainError { /// Position count. position_count: usize, }, + /// A basement fragment does not preserve a valid LOD0 source face and mask. + InvalidFoundationFragment { + /// Fragment index. + fragment: usize, + }, + /// Foundation fragments require a retained source Land.msh document. + FoundationRequiresMesh, /// Terrain Node38/Slot68 records do not assign every face to one LOD. InvalidLodMapping(TerrainLodError), } @@ -119,6 +136,12 @@ impl std::fmt::Display for TerrainError { f, "terrain face {face} references vertex {vertex} outside {position_count} positions" ), + Self::InvalidFoundationFragment { fragment } => { + write!(f, "invalid basement foundation fragment {fragment}") + } + Self::FoundationRequiresMesh => { + write!(f, "basement foundation requires a source Land.msh") + } Self::InvalidLodMapping(error) => write!(f, "invalid terrain LOD mapping: {error}"), } } @@ -132,7 +155,9 @@ impl std::error::Error for TerrainError { | Self::TooManyAreals { .. } | Self::InvalidGridReference { .. } | Self::InvalidArealReference { .. } - | Self::InvalidSurfaceVertex { .. } => None, + | Self::InvalidSurfaceVertex { .. } + | Self::InvalidFoundationFragment { .. } + | Self::FoundationRequiresMesh => None, } } } @@ -254,6 +279,7 @@ impl TerrainWorld { surfaces: Vec::new(), surface_bvh: SurfaceBvh::default(), source_mesh: None, + basement_foundation: None, }) } @@ -290,6 +316,87 @@ impl TerrainWorld { Ok(world) } + /// Returns a new world whose surface queries use exactly the canonical + /// source and generated triangles from a basement foundation. + /// + /// # Errors + /// + /// Returns [`TerrainError`] if this world has no source mesh or any + /// fragment does not preserve valid LOD0 identity, flags, and geometry. + pub fn with_basement_foundation( + mut self, + foundation: BasementFoundation, + ) -> Result<Self, TerrainError> { + let mesh = self + .source_mesh + .as_ref() + .ok_or(TerrainError::FoundationRequiresMesh)?; + let assignments = mesh + .face_lod_assignments() + .map_err(TerrainError::InvalidLodMapping)?; + let is_lod0 = assignments + .iter() + .map(|assignment| assignment.lod == TerrainLod::Lod0) + .collect::<Vec<_>>(); + let mut surfaces = Vec::with_capacity(foundation.fragments.len()); + let mut generated = 0_usize; + for (fragment_index, fragment) in foundation.fragments.iter().enumerate() { + if !valid_foundation_triangle(fragment.positions) { + return Err(TerrainError::InvalidFoundationFragment { + fragment: fragment_index, + }); + } + let face = if let Some(source_face) = fragment.source_face { + let Some(source) = mesh.faces.get(source_face) else { + return Err(TerrainError::InvalidFoundationFragment { + fragment: fragment_index, + }); + }; + if !is_lod0.get(source_face).copied().unwrap_or(false) + || source.flags != fragment.surface_mask + { + return Err(TerrainError::InvalidFoundationFragment { + fragment: fragment_index, + }); + } + source_face + } else { + if basement_surface_mask(fragment.positions) != fragment.surface_mask { + return Err(TerrainError::InvalidFoundationFragment { + fragment: fragment_index, + }); + } + let face = mesh.faces.len().checked_add(generated).ok_or( + TerrainError::InvalidFoundationFragment { + fragment: fragment_index, + }, + )?; + generated = + generated + .checked_add(1) + .ok_or(TerrainError::InvalidFoundationFragment { + fragment: fragment_index, + })?; + face + }; + surfaces.push(RuntimeTriangle { + face, + mask: fragment.surface_mask, + vertices: fragment.positions, + }); + } + self.surfaces = surfaces; + self.surface_bvh = SurfaceBvh::from_surfaces(&self.surfaces); + self.basement_foundation = Some(foundation); + Ok(self) + } + + /// Returns the canonical basement geometry used by the surface query index. + #[must_use] + pub fn basement_foundation(&self) -> Option<&BasementFoundation> { + self.basement_foundation.as_ref() + } + /// Returns the number of navigation areas. #[must_use] pub fn areal_count(&self) -> usize { @@ -740,26 +847,31 @@ impl RuntimeTriangle { } fn raycast(&self, origin: [f32; 3], direction: [f32; 3]) -> Option<f32> { - let edge1 = sub3(self.vertices[1], self.vertices[0]); - let edge2 = sub3(self.vertices[2], self.vertices[0]); - let pvec = cross3(direction, edge2); - let det = dot3(edge1, pvec); - if det.abs() <= 1.0e-6 { + let edge1 = sub3_f64(self.vertices[1], self.vertices[0]); + let edge2 = sub3_f64(self.vertices[2], self.vertices[0]); + let direction = direction.map(f64::from); + let pvec = cross3_f64(direction, edge2); + let det = dot3_f64(edge1, pvec); + if det == 0.0 || !det.is_finite() { return None; } let inv_det = 1.0 / det; - let tvec = sub3(origin, self.vertices[0]); - let u = dot3(tvec, pvec) * inv_det; + let tvec = sub3_f64(origin, self.vertices[0]); + let u = dot3_f64(tvec, pvec) * inv_det; if !(-1.0e-5..=1.00001).contains(&u) { return None; } - let qvec = cross3(tvec, edge1); - let v = dot3(direction, qvec) * inv_det; + let qvec = cross3_f64(tvec, edge1); + let v = dot3_f64(direction, qvec) * inv_det; if v < -1.0e-5 || u + v > 1.00001 { return None; } - let distance = dot3(edge2, qvec) * inv_det; - (distance >= 0.0).then_some(distance) + let distance = dot3_f64(edge2, qvec) * inv_det; + if distance >= 0.0 && distance.is_finite() && distance <= f64::from(f32::MAX) { + Some(distance as f32) + } else { + None + } } } @@ -919,6 +1031,21 @@ fn build_surfaces(mesh: &LandMeshDocument) -> Result<Vec<RuntimeTriangle>, Terra Ok(triangles) } +fn valid_foundation_triangle(vertices: [[f32; 3]; 3]) -> bool { + if !vertices.iter().flatten().all(|value| value.is_finite()) { + return false; + } + let first = [ + f64::from(vertices[1][0]) - f64::from(vertices[0][0]), + f64::from(vertices[1][1]) - f64::from(vertices[0][1]), + ]; + let second = [ + f64::from(vertices[2][0]) - f64::from(vertices[0][0]), + f64::from(vertices[2][1]) - f64::from(vertices[0][1]), + ]; + first[0] * second[1] - first[1] * second[0] != 0.0 +} + fn surface_vertex( mesh: &LandMeshDocument, face: usize, @@ -940,25 +1067,41 @@ fn barycentric_2d( second: [f32; 2], third: [f32; 2], ) -> Option<[f32; 3]> { - let edge_second = [second[0] - first[0], second[1] - first[1]]; - let edge_third = [third[0] - first[0], third[1] - first[1]]; - let point_delta = [point[0] - first[0], point[1] - first[1]]; + let edge_second = [ + f64::from(second[0]) - f64::from(first[0]), + f64::from(second[1]) - f64::from(first[1]), + ]; + let edge_third = [ + f64::from(third[0]) - f64::from(first[0]), + f64::from(third[1]) - f64::from(first[1]), + ]; + let point_delta = [ + f64::from(point[0]) - f64::from(first[0]), + f64::from(point[1]) - f64::from(first[1]), + ]; let denom = edge_second[0] * edge_third[1] - edge_third[0] * edge_second[1]; - if denom.abs() <= 1.0e-6 { + if denom == 0.0 { return None; } - let inv = 1.0 / denom; - let second_weight = (point_delta[0] * edge_third[1] - edge_third[0] * point_delta[1]) * inv; - let third_weight = (edge_second[0] * point_delta[1] - point_delta[0] * edge_second[1]) * inv; + let second_weight = (point_delta[0] * edge_third[1] - edge_third[0] * point_delta[1]) / denom; + let third_weight = (edge_second[0] * point_delta[1] - point_delta[0] * edge_second[1]) / denom; let first_weight = 1.0 - second_weight - third_weight; - Some([first_weight, second_weight, third_weight]) + let weights = [first_weight, second_weight, third_weight]; + if weights.iter().any(|weight| !weight.is_finite()) { + return None; + } + Some(weights.map(|weight| weight as f32)) } -fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { - [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +fn sub3_f64(a: [f32; 3], b: [f32; 3]) -> [f64; 3] { + [ + f64::from(a[0]) - f64::from(b[0]), + f64::from(a[1]) - f64::from(b[1]), + f64::from(a[2]) - f64::from(b[2]), + ] } -fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { +fn cross3_f64(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], @@ -966,7 +1109,7 @@ fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { ] } -fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 { +fn dot3_f64(a: [f64; 3], b: [f64; 3]) -> f64 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } @@ -1127,6 +1270,177 @@ mod tests { } #[test] + fn foundation_world_queries_use_the_canonical_cut_and_generated_band() { + use crate::{build_basement_foundations, BasementFootprint}; + + let mesh = synthetic_land_mesh(); + let outer = [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]; + let inner = [ + [0.4, 0.4, 1.0], + [0.6, 0.4, 1.0], + [0.6, 0.6, 1.0], + [0.4, 0.6, 1.0], + ]; + let foundation = build_basement_foundations( + &mesh, + &[BasementFootprint { + outer: &outer, + inner: &inner, + flatten_inner_to_mean_outer_z: true, + }], + ) + .expect("foundation"); + let expected_fragments = foundation.fragments.clone(); + let expected_fragment_count = foundation.fragments.len(); + let world = TerrainWorld::from_land_msh(&mesh) + .expect("source world") + .with_basement_foundation(foundation) + .expect("foundation world"); + + assert_eq!(world.surface_count(), expected_fragment_count); + assert_eq!( + world + .basement_foundation() + .map(|value| value.fragments.as_slice()), + Some(expected_fragments.as_slice()) + ); + assert!(world + .height_at([0.05, 0.05]) + .expect("band height") + .is_some()); + assert!(world.height_at([0.3, 0.3]).expect("band height").is_some()); + assert_eq!(world.height_at([0.5, 0.5]).expect("hole height"), None); + assert_eq!( + world + .raycast([0.5, 0.5, 4.0], [0.0, 0.0, -1.0], FullSurfaceMask(0),) + .expect("hole ray"), + None + ); + + let mut generated_face = mesh.faces.len(); + for (runtime, fragment) in world.surfaces.iter().zip(&expected_fragments) { + assert_eq!(runtime.mask, fragment.surface_mask); + assert_eq!(runtime.vertices, fragment.positions); + if let Some(source_face) = fragment.source_face { + assert_eq!(runtime.face, source_face); + assert_eq!(runtime.mask, mesh.faces[source_face].flags); + } else { + assert_eq!(runtime.face, generated_face); + generated_face += 1; + } + } + assert!(generated_face > mesh.faces.len()); + } + + #[test] + fn foundation_install_accepts_thin_nonzero_fragments_for_surface_queries() { + let mesh = synthetic_land_mesh(); + let fragment = BasementFoundationFragment { + source_face: Some(0), + surface_mask: mesh.faces[0].flags, + positions: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.000_000_5, 0.0]], + }; + let foundation = BasementFoundation { + fragments: vec![fragment], + placements: Vec::new(), + }; + let world = TerrainWorld::from_land_msh(&mesh) + .expect("source world") + .with_basement_foundation(foundation) + .expect("thin but non-degenerate triangle"); + + assert_eq!(world.surface_count(), 1); + assert_eq!( + world.height_at([0.25, 0.000_000_125]).expect("height"), + Some(0.0) + ); + assert!(world + .raycast( + [0.25, 0.000_000_125, 1.0], + [0.0, 0.0, -1.0], + FullSurfaceMask(0), + ) + .expect("raycast") + .is_some()); + } + + #[test] + fn foundation_install_rejects_invalid_source_identity_masks_and_generated_flags() { + let mesh = synthetic_land_mesh(); + let source_triangle = [[0.0, 0.0, 0.0], [1.0, 0.0, 1.0], [0.0, 1.0, 1.0]]; + let generated_triangle = [[0.0, 0.0, 0.0], [0.25, 0.0, 0.0], [0.0, 0.25, 0.0]]; + let invalid = [ + BasementFoundationFragment { + source_face: Some(mesh.faces.len()), + surface_mask: mesh.faces[0].flags, + positions: source_triangle, + }, + BasementFoundationFragment { + source_face: Some(0), + surface_mask: FullSurfaceMask(mesh.faces[0].flags.0 ^ 1), + positions: source_triangle, + }, + BasementFoundationFragment { + source_face: None, + surface_mask: FullSurfaceMask(0x0300), + positions: generated_triangle, + }, + ]; + + for (index, fragment) in invalid.into_iter().enumerate() { + let foundation = BasementFoundation { + fragments: vec![fragment], + placements: Vec::new(), + }; + assert!( + matches!( + TerrainWorld::from_land_msh(&mesh) + .expect("source world") + .with_basement_foundation(foundation), + Err(TerrainError::InvalidFoundationFragment { fragment: 0 }) + ), + "invalid fragment case {index}" + ); + } + } + + #[test] + fn foundation_install_rejects_lod1_source_faces() { + use fparkan_terrain_format::TerrainLod; + + let mut mesh = synthetic_land_mesh(); + mesh.positions.push([0.0, 0.0, 2.0]); + mesh.positions.push([1.0, 0.0, 2.0]); + mesh.positions.push([0.0, 1.0, 2.0]); + mesh.faces + .push(terrain_face(FullSurfaceMask(0x0000_0003), [4, 5, 6])); + mesh.nodes_raw[10..12].copy_from_slice(&1_u16.to_le_bytes()); + let mut lod1_slot = [0_u8; 68]; + lod1_slot[0..2].copy_from_slice(&2_u16.to_le_bytes()); + lod1_slot[2..4].copy_from_slice(&1_u16.to_le_bytes()); + mesh.slots.slots_raw.push(lod1_slot); + assert_eq!( + mesh.face_lod_assignments().expect("LOD assignment")[2].lod, + TerrainLod::Lod1 + ); + + let foundation = BasementFoundation { + fragments: vec![BasementFoundationFragment { + source_face: Some(2), + surface_mask: mesh.faces[2].flags, + positions: [[0.0, 0.0, 2.0], [1.0, 0.0, 2.0], [0.0, 1.0, 2.0]], + }], + placements: Vec::new(), + }; + assert!(matches!( + TerrainWorld::from_land_msh(&mesh) + .expect("source world") + .with_basement_foundation(foundation), + Err(TerrainError::InvalidFoundationFragment { fragment: 0 }) + )); + } + + #[test] fn surface_bvh_reduces_candidates_without_changing_surface_math() { let surfaces: Vec<_> = (0_u16..16) .map(|face| { diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md index 8045d68..29d85f9 100644 --- a/docs/reference/environment-effects.md +++ b/docs/reference/environment-effects.md @@ -207,6 +207,112 @@ packed: [key.colors[10], key.colors[14]], `AtmosphereFrame` пока не несёт этот бит обновления, поэтому sampler выдаёт интерполированный цвет при каждом запросе. +## Экранная геометрия солнца и луны + +`CSun::Render` сначала переводит направление небесного тела в пиксели текущего +viewport, затем собирает quad с native XYZRHW-позицией. При положительной высоте +viewport Vulkan пиксели переводятся в clip-space так: + +```rust +position = [ + pixel[0] * (2.0 / width) - 1.0, + pixel[1] * (2.0 / height) - 1.0, + 0.999, +]; +``` + +Vertex shader пропускает `clip_from_world` для строк солнца и луны. CPU каждый +кадр пересчитывает центр и размер quad по текущему viewport; старые пиксельные +координаты не переносятся на новый размер. World-space billboards бликов +остаются отдельным путём. Материал экранного quad не освещается, vertex fog +отключён, а sky far-depth bit к нему не применяется. + +Native `z = 0.999` не означает, что спрайт гарантированно лежит за любой +поверхностью. Vulkan использует `LESS_OR_EQUAL` и не записывает depth для +прозрачного ряда. При near `0.5`, far `700` мировая поверхность на расстоянии +`320` получает depth +`700 / (700 - 0.5) * (1 - 0.5 / 320) ≈ 0.999151`; поэтому экранный `0.999` +чуть ближе этой поверхности и проходит depth test перед ней. Это фиксированное +native значение сохраняется для совместимости, но само по себе не обещает +окклюзию солнца всей геометрией местности. + +## Геометрия визуальных FX-команд + +`fparkan-fx::fx_visual` формирует кадры для двух подтверждённых вариантов +`f_gener_ball`. Op4 принимает mode 0 с положительным знаменателем в `body+0xC4` +и выдаёт camera-facing quad; расстояние камеры считается через native +component-wise projection родительской матрицы. Op9 принимает mode 2: +селектор `body+0xC4` со значением 0, 1 или 2 отображается в native flag base +3, 5 или 6 и выбирает одну из трёх сеток; категория render flags выбирает её +детализацию. `body+0xC8` выбирает UV path: +нулевое значение проецирует позицию вершины на XY, ненулевое использует +фиксированные координаты quad и cap. Кадр сохраняет MAT0 resource, фазу, +opacity, вершины и triangle indices для renderer. + +Evaluator получает готовые command body, phase, camera point и внешнюю +матрицу. Выбор Control row, manager gates, owner attachment, header jitter и +global RNG остаются задачей владельца. `fparkan-game` пока использует для Op9 +категорию 0 с наибольшей детализацией; выбор категории по projected size ещё +не реализован. + +Для Op3 поддерживаются body-типы 0, 1 и 2. Поля отсчитываются от +`FxCommand.raw_body`, то есть после 4-байтового command word. Типы 0 и 1 +используют общие position/scale/phase/opacity tracks и camera-facing основу; +тип 1 и тип 2 также интерполируют ось, а тип 2 строит basis через native +D110-порядок. Все три evaluator-а принимают уже собранную матрицу владельца и +не выбирают Control row или attachment. Текущий путь для типов 0 и 1 +предполагает native render context 0: `Effect.dll` проверяет общий gate +`context + 0x60 < 8`, а API этих evaluator-ов не выбирает другой context. +Evaluator типа 2 принимает индекс context явно и учитывает этот gate в `active`. + +## Частицы Op7 + +`fparkan-fx::op7` вычисляет фиксированный набор частиц Op7 по body команды, +фазе эффекта, матрице владельца, позиции камеры и render context. После +инициализации состояние команды сохраняет случайно изменённые треки позиции +и масштаба. Body задаёт время групп, opacity, фазу материала и вертикальное +ускорение; последнее масштабируется переданным world scalar и применяется к +копии матрицы владельца каждой активной группы. Текущий preview +`fparkan-game` передаёт `world_scalar = 10.0`; чтение действующей настройки +мира пока не подключено. + +Часы активной группы обновляются до применения detail cutoff отдельной +частицы. Cutoff сравнивает ordinal render context с постоянным порогом слота. +`Effect.dll` `0x1300` использует тот же порядок, поэтому скрытие слота не +останавливает часы его активной группы. Поток частиц последовательно задаёт +случайные изменения позиции и масштаба; для порогов видимости используется +отдельный поток. Шаг LFSR совпадает с нативным переходом двух половин, однако +семена отдельных экземпляров лишь детерминированно заменяют process-global +состояние и не воспроизводят общий порядок вызовов. + +## Частицы Op8 + +`fparkan-fx::smoke` разбирает body Op8, хранит кольцевой пул команды и выдаёт +quad-ы с ориентацией к камере для активных слотов. Нативная нормализованная +фаза окна ограничивается диапазоном 0–1 и задаёт интервал проверки следующего события; +сравнение строгое (`schedule_base + interval < update_clock`). Все события +catch-up в одном обновлении проверяются по тому же нормализованному интервалу; +после события нативный код продвигает базу расписания интервалом, вычисленным +по сырой фазе эффекта, а возраст слота считает по нормализованному интервалу. +Когда фаза равна единственной границе окна, деление `0/0` даёт NaN, который +нативный unordered-путь сравнения ограничивает нулём. + +Нулевая база расписания служит признаком начального состояния: обновление +сохраняет текущую опору эмиттера и базу времени, не создавая частицу. Если +первое время равно нулю, следующее обновление повторно захватывает опору. При +событии скорость опоры считается от сохранённой позиции до текущей за время +от базы расписания; слот смещает сохранённую опору на `скорость * due_interval`. +Обновление без событий не переносит сохранённую опору к текущему владельцу. +Откат часов корректирует только базу расписания до текущего времени: слоты, +опора и три потока случайных +чисел не сбрасываются. + +Evaluator принимает отдельные начальные состояния для эмиттера, знаковых +отклонений и материала. Переход каждого потока совпадает с общим native LFSR, +но эти локальные seed не воспроизводят порядок process-global вызовов игры. +Предел 100000 событий catch-up — защитная граница evaluator-а; соответствующего +лимита в исследованном native цикле не обнаружено. + ## Что остаётся за владельцем `EnvironmentSystem` не извлекает камеру из renderer, не разрешает материалы diff --git a/docs/reference/materials.md b/docs/reference/materials.md index c148bc8..435bd6e 100644 --- a/docs/reference/materials.md +++ b/docs/reference/materials.md @@ -132,6 +132,13 @@ ping-pong нечётный цикл идёт в обратном направлÐ sampler `animation_block_index` уже выбран вызывающим runtime из block table; packed WEAR handle и номер строки WEAR являются отдельными значениями. +Для native effect path с явной normalized phase режим блока не вычисляется: +конечное значение вне `0..=1` заменяется на `0.5`, затем phase умножается на +последний `end_time_ms` и усекается до целого tick. Поиск интервала идёт от +последнего ключа к первому. При phase `1.0` tick равен duration, поэтому ни один +интервал не подходит: выбирается key 0 и общий interpolation path может +экстраполировать за пределы его первого интервала. + ## Fallback Material resolve: diff --git a/docs/reference/msh.md b/docs/reference/msh.md index 165b750..a28514d 100644 --- a/docs/reference/msh.md +++ b/docs/reference/msh.md @@ -153,3 +153,50 @@ Byte `material_index` выбирает строку WEAR; high word at `+0x02` Ñ `index_start + index_count` остаётся внутри type 6, а каждый использованный индекс через `base_vertex` попадает в position stream. `vertex_count` и `local_batch_index` доступны renderer для исходного draw contract. + +## Сторона грани в цветовом проходе + +В цветном проходе `Terrain.dll`, функция `0x100296F0`, сравнивает камеру с +каждой гранью по знаку тройного произведения: + +```c +d = dot(eye - v0, cross(v1 - v0, v2 - v0)); +``` + +Проход оставляет грань только при `d > 0`. Raw `Batch20.flags` задаёт два +исключения: `0x0002` пропускает программную проверку стороны, поэтому обратная +грань не отбрасывается этим условием. С `0x4000` проверяется только первая +грань записи: если `d <= 0`, цикл её граней прекращается; если `d > 0`, +оставшиеся грани этой записи уже не проходят повторную программную проверку. +`Terrain.dll!0x100296F0` также настраивает пакет этого цветного пути: flags по +адресу `packet+0x128` равны `0x404` или `0x414`. Путь отправки в +`Terrain.dll!0x1001D660` перед вызовом renderer vtable `+0x48` очищает только +маску `0x1`; `Ngi32.dll!0x10007470` проверяет маску `0x4` этого аргумента. При +установленной маске render state 22 получает `D3DCULL_NONE`, иначе — +`D3DCULL_CW`. Оба значения CShade сохраняют маску `0x4`, поэтому в этом +цветном пути аппаратное отсечение выключено, а набор обычных граней определяет +программный тест `d > 0`. Raw-флаги `0x0002` и `0x4000` меняют этот тест, как +описано выше. + +Статический Vulkan preview сохраняет каждую исходную запись `Batch20` одним +диапазоном рисования: +`append_static_preview_component_with_lightmaps` добавляет исходные индексы без +перестановки, а в диапазон переносит `index_count` и исходный `first_index` с +общим смещением компонента. Поэтому `0x4000` выбирает ту же первую грань, даже +если диапазон получил другой материал для lightmap. + +Поскольку вершины preview переведены в мировые координаты, знак `d` для MSH +диапазона корректируется знаком детерминанта полной матрицы размещения. Повороты +узлов и mounts — единичные кватернионы с determinant `+1`; отрицательный object +scale меняет знак. Renderer Vulkan задаёт `front_face=CLOCKWISE` и viewport с +положительной высотой. Для используемых матриц legacy и free-flight preview +при положительном determinant native-visible `d > 0` относится к Vulkan +back-face; поэтому preview отсекает `FrontFace`. При отрицательном determinant +режим меняется на `BackFace`. +Для `0x0002` и `0x4000` аппаратное отсечение выключено, чтобы сохранить их +программную политику. Отклонённый `0x4000` диапазон получает число индексов 0; +каждый кадр это число заново вычисляется из исходного значения, поэтому после +изменения положения камеры или opacity материала диапазон может вернуться в +draw queue. Проверка первой грани +применяется только к MSH-диапазонам цветовой мировой очереди; terrain и +shadow/sun запросы её не используют. diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index b04af31..c8bdf4e 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -625,15 +625,20 @@ Lightmap не является обычной diffuse texture. WEAR содерж отдельным slot или texture stage. Замена lightmap предварительным умножением в diffuse texture ломает LOD, atlas coordinates и динамическую модуляцию. -Vulkan preview строит projected shadows для выбранных зданий и роботов, -отбрасывает spheres вне шести clip-плоскостей с радиусом `sphere + 100` и +Vulkan preview строит проецируемые тени для объектов, отбрасывающих тени, +отбрасывает сферы вне шести clip-плоскостей с радиусом `sphere + 100` и сохраняет порядок видимых кандидатов; frame builder обрабатывает не более 20 -casters. Текущая реализация выделяет страницы в atlas `256 x 256` с 28 page -slots и использует одну smoothing pass. Вызов preview передаёт `fade = 1.0` и -LOD defaults: minimum LOD `1` и detail multiplier `1.0` отдельно для зданий и -роботов. Rust-путь не загружает `shade.cfg`: эти значения задаются кодом, а -параметры файла пока не влияют на preview. Проекция учитывает вклад -directional и point lights. +объектов. Текущая реализация выделяет страницы в atlas `256 x 256` с 28 page +slots и использует одно сглаживание. Preview применяет native defaults +`RobShadowsOn = true` и `BldShadowsOn = false`: объект kind `4` проверяет +первый флаг, остальные kinds — второй. Поэтому роботы BTLU остаются объектами, +отбрасывающими тени, а объекты FORT и STAT их не создают. Preview пока не +загружает `shade.cfg`, поэтому значения из пользовательского файла не меняют +список объектов, отбрасывающих тени. Проверка действует только на проецируемые +тени: отдельный список объектов для затенения солнца по-прежнему включает +FORT, BTLU и STAT. Вызов preview передаёт `fade = 1.0` и LOD defaults: +minimum LOD `1` и detail multiplier `1.0` отдельно для зданий и роботов. +Проекция учитывает вклад directional и point lights. Receiver projection обрезает projected quad по рёбрам треугольника. Native ray query descriptor в `+0x10` требует наличия всех переданных bits, а descriptor @@ -955,6 +960,23 @@ for (face, assignment) in mesh.faces.iter().zip(assignments.iter()) { } ``` +Подготовка BAS вынесена в `fparkan-terrain::build_basement_foundations`. +Функция получает внешние и внутренние кольца в порядке размещения, строит +упорядоченные пересечения внешней границы с текущей поверхностью и возвращает +исходные фрагменты рельефа вместе с треугольниками отмостки в одном наборе. +`TerrainWorld::with_basement_foundation` принимает этот набор во владение и +устанавливает те же координаты и маски в BVH высотных запросов и пересечений. +Предпросмотр миссии на Vulkan пока строит только LOD0: связывание BAS-записей +с этой подготовкой ещё не подключено, поэтому наличие API не означает, что +геометрия миссии уже обрезана. + +Внешнее кольцо должно лежать на области LOD0, а каждый его сегмент — оставаться +покрытым текущей поверхностью. Непокрытый сегмент или пересекающиеся контуры +дают ошибку; карта не остаётся частично обрезанной. Первичная нормализация +использует исходные грани и соседей LOD0, последующие пересечения и вырезания — +канонические фрагменты после предыдущих размещений. Соответствие динамических +соседей, создаваемых нативным движком, пока не моделируется. + ## Материалы ландшафта Рядом с `Land.msh` лежат `Land1.wea` и `Land2.wea`. Старший байт |
