diff options
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.vert | 36 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.vert.spv | bin | 19136 -> 20096 bytes | |||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi.rs | 22 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/resources.rs | 202 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/smoke.rs | 143 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs | 19 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs | 163 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/tests.rs | 45 | ||||
| -rw-r--r-- | crates/fparkan-assets/src/lib.rs | 1276 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/environment.rs | 27 | ||||
| -rw-r--r-- | crates/fparkan-prototype/src/lib.rs | 543 | ||||
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 110 | ||||
| -rw-r--r-- | crates/fparkan-terrain-format/src/lib.rs | 34 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 161 | ||||
| -rw-r--r-- | docs/reference/environment-effects.md | 4 | ||||
| -rw-r--r-- | docs/tomes/06-behavior.md | 9 |
16 files changed, 2641 insertions, 153 deletions
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert index 83a1751..d1e2e04 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.vert +++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert @@ -39,6 +39,17 @@ layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular { vec4 overlay; } material_specular; +struct PointLightRecord { + vec4 position_range; + vec4 rgb_active; + vec4 coefficients_active; +}; + +layout(std430, set = 0, binding = 6) readonly buffer PointLightBuffer { + uvec4 header; + PointLightRecord records[]; +} point_light_buffer; + layout(push_constant) uniform MaterialConstants { layout(offset = 0) float alpha_cutoff; layout(offset = 4) float diffuse_alpha; @@ -180,29 +191,30 @@ LightingTerms native_material_lighting( 1.0 ); } - vec4 point_position_range = frame.point_position_range; - vec4 point_rgb_active = frame.point_rgb_active; - vec4 point_coefficients_active = frame.point_coefficients_active; - vec3 to_light = point_position_range.xyz - world_position; - float distance_squared = dot(to_light, to_light); - float range = point_position_range.w; - if (point_rgb_active.w > 0.5 && range > 0.0 && distance_squared <= range * range) { + for (uint index = 0u; index < point_light_buffer.header.x; ++index) { + PointLightRecord point_light = point_light_buffer.records[index]; + vec3 to_light = point_light.position_range.xyz - world_position; + float distance_squared = dot(to_light, to_light); + float range = point_light.position_range.w; + if (point_light.rgb_active.w <= 0.5 || range <= 0.0 || distance_squared > range * range) { + continue; + } float distance_to_light = sqrt(distance_squared); if (distance_to_light > 0.000001) { vec3 light_direction = to_light / distance_to_light; float ndotl = dot(normal, light_direction); if (ndotl > 0.0) { float t = (range - distance_to_light) / range; - float attenuation = point_coefficients_active.x - + point_coefficients_active.y * t - + point_coefficients_active.z * t * t; + float attenuation = point_light.coefficients_active.x + + point_light.coefficients_active.y * t + + point_light.coefficients_active.z * t * t; attenuation = max(attenuation, 0.0); - accumulated += ndotl * directional_rgb * point_rgb_active.rgb * attenuation; + accumulated += ndotl * directional_rgb * point_light.rgb_active.rgb * attenuation; specular += specular_from_light( normal, view_direction, light_direction, - point_rgb_active.rgb, + point_light.rgb_active.rgb, specular_rgb, specular_power, attenuation diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv Binary files differindex 535bde0..f74b1f8 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.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 7cf192c..9584405 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -51,12 +51,13 @@ use self::instance::{cstring_vec, ensure_instance_extensions_available}; use self::resources::{ color_subresource_range, create_command_pool, create_depth_attachment, create_frame_sync, create_frame_uniform_buffer, create_index_buffer, create_material_specular_buffer, - create_readback_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer, - create_static_texture_image, create_vertex_buffer, destroy_allocated_buffer, - destroy_allocated_image, destroy_depth_attachment, readback_buffer_bytes, - update_static_texture_image, write_frame_uniform, write_material_specular_buffer, - write_static_mesh_index_buffer, write_static_mesh_vertex_buffer, VulkanAllocatedBuffer, - VulkanAllocatedImage, VulkanDepthAttachment, VulkanFrameSync, + create_point_light_buffer, create_readback_buffer, create_static_mesh_index_buffer, + create_static_mesh_vertex_buffer, create_static_texture_image, create_vertex_buffer, + destroy_allocated_buffer, destroy_allocated_image, destroy_depth_attachment, + readback_buffer_bytes, update_static_texture_image, write_frame_uniform, + write_material_specular_buffer, write_point_light_buffer, write_static_mesh_index_buffer, + write_static_mesh_vertex_buffer, VulkanAllocatedBuffer, VulkanAllocatedImage, + VulkanDepthAttachment, VulkanFrameSync, }; pub use self::runtime::{ create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request, @@ -69,8 +70,9 @@ pub use self::smoke_types::{ VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanStaticTextureMip, VulkanStaticVertex, VulkanValidationReport, - VULKAN_DIRECTIONAL_LIGHT_COUNT, VULKAN_FRAME_UNIFORM_BYTES, - VULKAN_MATERIAL_SPECULAR_RECORD_BYTES, + VULKAN_DIRECTIONAL_LIGHT_COUNT, VULKAN_FRAME_SLOT_COUNT, VULKAN_FRAME_UNIFORM_BYTES, + VULKAN_MATERIAL_SPECULAR_RECORD_BYTES, VULKAN_POINT_LIGHT_HEADER_BYTES, + VULKAN_POINT_LIGHT_RECORD_BYTES, }; #[cfg(test)] use self::surface::extension_name; @@ -83,8 +85,8 @@ pub use self::swapchain::{ VulkanSwapchainProbeError, VulkanSwapchainReport, }; use self::swapchain_resources::{ - create_swapchain_resources, destroy_swapchain_resources, update_material_texture_descriptor, - VulkanSwapchainResources, + create_swapchain_resources, destroy_swapchain_resources, ensure_point_light_capacity, + update_material_texture_descriptor, VulkanSwapchainResources, }; use self::validation::{create_validation_messenger, VulkanValidationMessenger}; use ash::vk; diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs index b841588..0b43565 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs @@ -4,8 +4,8 @@ use ash::vk; use fparkan_platform::DepthStencilSupport; use super::{ - VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanStaticMesh, - VulkanStaticTexture, + VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanPointLight, VulkanSmokeRendererError, + VulkanStaticMesh, VulkanStaticTexture, }; use crate::select_depth_stencil_attachment_format; @@ -520,7 +520,6 @@ pub(super) fn create_frame_uniform_buffer( instance: &VulkanInstanceProbe, device: &VulkanLogicalDeviceProbe, ) -> Result<(VulkanAllocatedBuffer, u64), VulkanSmokeRendererError> { - const FRAME_SLOTS: u64 = 2; const FRAME_BYTES: u64 = super::VULKAN_FRAME_UNIFORM_BYTES as u64; let limits = // SAFETY: The physical device belongs to this live instance and the query returns a value copy. @@ -532,12 +531,11 @@ pub(super) fn create_frame_uniform_buffer( }, )? / alignment * alignment; - let size = - stride - .checked_mul(FRAME_SLOTS) - .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { - context: "frame uniform buffer size overflow", - })?; + let size = stride + .checked_mul(super::VULKAN_FRAME_SLOT_COUNT as u64) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "frame uniform buffer size overflow", + })?; let bytes = vec![ 0_u8; usize::try_from(size).map_err(|_| { @@ -556,6 +554,192 @@ pub(super) fn create_frame_uniform_buffer( Ok((buffer, stride)) } +/// Returns the per-frame descriptor range, aligned slot stride, total bytes, +/// and maximum light count accepted by the device's storage-buffer limit. +pub(super) fn point_light_buffer_layout( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + capacity: usize, +) -> Result<(u64, u64, u64, usize), VulkanSmokeRendererError> { + let limits = + // SAFETY: The physical device belongs to this live instance and the query returns a value copy. + unsafe { instance.instance.get_physical_device_properties(device.physical_device()) } + .limits; + let max_range = u64::from(limits.max_storage_buffer_range); + let header_bytes = super::VULKAN_POINT_LIGHT_HEADER_BYTES as u64; + let record_bytes = super::VULKAN_POINT_LIGHT_RECORD_BYTES as u64; + if max_range < header_bytes { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "device storage-buffer range cannot hold the point-light header", + }); + } + let max_count = usize::try_from((max_range - header_bytes) / record_bytes).map_err(|_| { + VulkanSmokeRendererError::InvalidPointLights { + context: "device point-light capacity exceeds address space", + } + })?; + if capacity > max_count { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light count exceeds maxStorageBufferRange", + }); + } + let capacity_u64 = + u64::try_from(capacity).map_err(|_| VulkanSmokeRendererError::InvalidPointLights { + context: "point-light capacity exceeds address space", + })?; + let range = header_bytes + .checked_add(record_bytes.checked_mul(capacity_u64).ok_or( + VulkanSmokeRendererError::InvalidPointLights { + context: "point-light descriptor range overflow", + }, + )?) + .ok_or(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light descriptor range overflow", + })?; + let alignment = u64::from(limits.min_storage_buffer_offset_alignment).max(1); + let stride = + range + .checked_add(alignment - 1) + .ok_or(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light frame stride overflow", + })? + / alignment + * alignment; + let total = stride + .checked_mul(u64::try_from(super::VULKAN_FRAME_SLOT_COUNT).unwrap_or(u64::MAX)) + .ok_or(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light buffer size overflow", + })?; + if stride + .checked_mul(u64::try_from(super::VULKAN_FRAME_SLOT_COUNT - 1).unwrap_or(u64::MAX)) + .is_none_or(|last_offset| last_offset > u64::from(u32::MAX)) + { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light dynamic offset exceeds Vulkan's 32-bit limit", + }); + } + Ok((range, stride, total, max_count)) +} + +/// Allocates a fence-safe point-light SSBO with one aligned segment per frame. +pub(super) fn create_point_light_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + capacity: usize, +) -> Result<(VulkanAllocatedBuffer, u64, u64, usize), VulkanSmokeRendererError> { + let (range, stride, total, max_count) = point_light_buffer_layout(instance, device, capacity)?; + let bytes = + vec![ + 0_u8; + usize::try_from(total).map_err(|_| VulkanSmokeRendererError::InvalidPointLights { + context: "point-light buffer size exceeds address space", + })? + ]; + let buffer = create_host_visible_buffer( + instance, + device, + &bytes, + vk::BufferUsageFlags::STORAGE_BUFFER, + "point-light storage buffer", + )?; + Ok((buffer, range, stride, max_count)) +} + +pub(super) fn encode_point_lights( + lights: &[VulkanPointLight], +) -> Result<Vec<u8>, VulkanSmokeRendererError> { + let count = + u32::try_from(lights.len()).map_err(|_| VulkanSmokeRendererError::InvalidPointLights { + context: "point-light count exceeds the storage header", + })?; + let total = super::VULKAN_POINT_LIGHT_HEADER_BYTES + .checked_add( + super::VULKAN_POINT_LIGHT_RECORD_BYTES + .checked_mul(lights.len()) + .ok_or(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light upload size overflow", + })?, + ) + .ok_or(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light upload size overflow", + })?; + let mut bytes = vec![0; total]; + bytes[0..4].copy_from_slice(&count.to_ne_bytes()); + let mut offset = super::VULKAN_POINT_LIGHT_HEADER_BYTES; + for light in lights { + for value in light + .position + .into_iter() + .chain([light.range]) + .chain(light.rgb) + .chain([if light.active { 1.0 } else { 0.0 }]) + .chain(light.coefficients) + .chain([if light.active { 1.0 } else { 0.0 }]) + { + bytes[offset..offset + std::mem::size_of::<f32>()] + .copy_from_slice(&value.to_ne_bytes()); + offset += std::mem::size_of::<f32>(); + } + } + debug_assert_eq!(offset, total); + Ok(bytes) +} + +/// Uploads the current light set into the frame slot whose fence has completed. +pub(super) fn write_point_light_buffer( + device: &VulkanLogicalDeviceProbe, + buffer: &VulkanAllocatedBuffer, + stride: u64, + frame_index: usize, + lights: &[VulkanPointLight], +) -> Result<(), VulkanSmokeRendererError> { + let slot = + u64::try_from(frame_index).map_err(|_| VulkanSmokeRendererError::InvariantViolation { + context: "point-light frame slot index", + })?; + if frame_index >= super::VULKAN_FRAME_SLOT_COUNT { + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "point-light frame slot is out of bounds", + }); + } + let offset = stride + .checked_mul(slot) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "point-light frame slot offset", + })?; + let bytes = encode_point_lights(lights)?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > stride { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light upload exceeds the allocated frame slot", + }); + } + let total = stride + .checked_mul(u64::try_from(super::VULKAN_FRAME_SLOT_COUNT).unwrap_or(u64::MAX)) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "point-light buffer mapped size", + })?; + let offset = + usize::try_from(offset).map_err(|_| VulkanSmokeRendererError::InvariantViolation { + context: "point-light frame slot offset exceeds address space", + })?; + // Map the whole allocation because the Vulkan minimum map alignment may + // exceed minStorageBufferOffsetAlignment used by the dynamic frame offset. + let mapped = unsafe { + device + .device() + .map_memory(buffer.memory, 0, total, vk::MemoryMapFlags::empty()) + } + .map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkMapMemory(point-light buffer)", + result, + })?; + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>().add(offset), bytes.len()); + device.device().unmap_memory(buffer.memory); + } + Ok(()) +} + /// Allocates one aligned storage-buffer record for every material descriptor. /// /// The descriptor uses a dynamic storage-buffer offset. Keeping the stride diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index ac359c2..b3d5ab3 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -11,14 +11,14 @@ use super::{ create_validation_messenger, create_vertex_buffer, create_vulkan_instance_probe, create_vulkan_logical_device_probe_for_request, create_vulkan_surface_probe, create_vulkan_swapchain_probe_for_extent, destroy_allocated_buffer, destroy_allocated_image, - destroy_swapchain_resources, plan_vulkan_surface, readback_buffer_bytes, - update_static_texture_image, write_frame_uniform, write_material_specular_buffer, - write_static_mesh_index_buffer, write_static_mesh_vertex_buffer, VulkanAllocatedBuffer, - VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanReadbackArtifact, - VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, - VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, - VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainResources, VulkanValidationMessenger, - VulkanValidationReport, + destroy_swapchain_resources, ensure_point_light_capacity, plan_vulkan_surface, + readback_buffer_bytes, update_static_texture_image, write_frame_uniform, + write_material_specular_buffer, write_point_light_buffer, write_static_mesh_index_buffer, + write_static_mesh_vertex_buffer, VulkanAllocatedBuffer, VulkanInstanceConfig, + VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanReadbackArtifact, VulkanSmokeFrameOutcome, + VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, + VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanSurfaceProbe, VulkanSwapchainProbe, + VulkanSwapchainResources, VulkanValidationMessenger, VulkanValidationReport, }; use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION; use crate::shader::validate_triangle_shaders; @@ -440,6 +440,8 @@ impl VulkanSmokeRenderer { readback_enabled: false, camera: create_info.camera, frame_uniforms: super::VulkanFrameUniforms::from_camera(create_info.camera), + point_lights: Vec::new(), + point_lights_explicit: false, frame_sync: Vec::new(), images_in_flight: Vec::new(), current_frame: 0, @@ -576,6 +578,41 @@ impl VulkanSmokeRenderer { Ok(()) } + /// Replaces the complete dynamic point-light set for subsequent frames. + /// + /// The storage buffer grows on demand and rejects counts beyond the + /// device's `maxStorageBufferRange`; it never truncates scene lights. + /// Values are uploaded to the current fence-safe frame slot by + /// [`Self::draw_frame`]. + pub fn set_point_lights( + &mut self, + point_lights: &[super::VulkanPointLight], + ) -> Result<(), VulkanSmokeRendererError> { + let max_count = self.resources_ref()?.max_point_light_count; + if point_lights.len() > max_count { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light count exceeds maxStorageBufferRange", + }); + } + if point_lights.iter().any(|light| { + !light + .position + .into_iter() + .chain(light.rgb) + .chain([light.range]) + .chain(light.coefficients) + .all(f32::is_finite) + }) { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "non-finite point-light field", + }); + } + self.point_lights.clear(); + self.point_lights.extend_from_slice(point_lights); + self.point_lights_explicit = true; + Ok(()) + } + /// Enables the optional swapchain image copy used by screenshot/readback /// callers. Runtime frames leave it disabled to avoid a transfer every /// frame. @@ -1318,6 +1355,44 @@ impl VulkanSmokeRenderer { context: "vkWaitForFences", result: error, })?; + let legacy_point_light = self.frame_uniforms.point_light; + let point_light_count = if self.point_lights_explicit { + self.point_lights.len() + } else { + usize::from(legacy_point_light.active) + }; + let needs_point_light_growth = + point_light_count > self.resources_ref()?.point_light_capacity; + if needs_point_light_growth { + // Updating every material's binding 6 and replacing the shared + // SSBO requires all prior command buffers to finish. + // SAFETY: The renderer owns the logical device and no submission + // can observe the replacement after this idle point. + unsafe { self.device_ref()?.device().device_wait_idle() }.map_err(|result| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkDeviceWaitIdle(point-light buffer growth)", + result, + } + })?; + let instance = + self.instance + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "Vulkan instance during point-light growth", + })?; + let device = + self.device + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "logical device during point-light growth", + })?; + let resources = self.swapchain_resources.as_mut().ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "swapchain resources during point-light growth", + }, + )?; + ensure_point_light_capacity(instance, device, resources, point_light_count)?; + } if self.material_specular_dirty { // The storage buffer is shared by all in-flight frames. Wait for // every prior use before replacing the records so a setter cannot @@ -1377,14 +1452,42 @@ impl VulkanSmokeRenderer { } self.images_in_flight[image_index_usize] = in_flight_fence; let frame_bytes = self.frame_uniforms.to_ne_bytes(); - let resources = self.resources_ref()?; - write_frame_uniform( - self.device_ref()?, - &resources.frame_uniform_buffer, - resources.frame_uniform_stride, - self.current_frame, - &frame_bytes, - )?; + { + let explicit_point_lights = self.point_lights_explicit; + let legacy_point_light = self.frame_uniforms.point_light; + let point_lights = if explicit_point_lights { + self.point_lights.as_slice() + } else if legacy_point_light.active { + std::slice::from_ref(&legacy_point_light) + } else { + &[] + }; + let device = + self.device + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "logical device during frame upload", + })?; + let resources = self.swapchain_resources.as_ref().ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "swapchain resources during frame upload", + }, + )?; + write_frame_uniform( + device, + &resources.frame_uniform_buffer, + resources.frame_uniform_stride, + self.current_frame, + &frame_bytes, + )?; + write_point_light_buffer( + device, + &resources.point_light_buffer, + resources.point_light_stride, + self.current_frame, + point_lights, + )?; + } let render_finished = self .resources_ref()? .render_finished @@ -1693,6 +1796,12 @@ impl VulkanSmokeRenderer { .saturating_mul(u64::try_from(texture_index).unwrap_or(u64::MAX)), ) .unwrap_or(u32::MAX); + let point_light_offset = u32::try_from( + resources + .point_light_stride + .saturating_mul(u64::try_from(self.current_frame).unwrap_or(u64::MAX)), + ) + .unwrap_or(u32::MAX); let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or( VulkanSmokeRendererError::InvariantViolation { context: "static material descriptor set", @@ -1704,7 +1813,7 @@ impl VulkanSmokeRenderer { resources.pipeline_layout, 0, std::slice::from_ref(descriptor_set), - &[frame_offset, material_offset], + &[frame_offset, material_offset, point_light_offset], ); let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes(); device.device().cmd_push_constants( diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 7a8837d..8abc53b 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -194,6 +194,15 @@ impl Default for VulkanPointLight { /// state. pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 368; +/// Number of CPU/GPU frame slots shared by the swapchain renderer. +pub const VULKAN_FRAME_SLOT_COUNT: usize = 2; + +/// `uvec4` header bytes for the dynamic point-light storage buffer. +pub const VULKAN_POINT_LIGHT_HEADER_BYTES: usize = 16; + +/// Three `vec4` records per dynamically uploaded point light. +pub const VULKAN_POINT_LIGHT_RECORD_BYTES: usize = 3 * 4 * std::mem::size_of::<f32>(); + /// Bytes occupied by one material's base and overlay specular records. /// /// Each record is one `vec4`: RGB followed by the native integer power stored @@ -1359,6 +1368,11 @@ pub enum VulkanSmokeRendererError { /// Validation failure detail. context: &'static str, }, + /// Submitted dynamic point-light data exceeds the Vulkan storage range or contains invalid values. + InvalidPointLights { + /// Validation failure detail. + context: &'static str, + }, /// The submitted static texture cannot be represented by this path. InvalidStaticTexture { /// Validation failure detail. @@ -1389,6 +1403,7 @@ impl std::fmt::Display for VulkanSmokeRendererError { Self::InvalidStaticCamera { context } => { write!(f, "invalid static camera: {context}") } + Self::InvalidPointLights { context } => write!(f, "invalid point lights: {context}"), Self::InvalidStaticTexture { context } => { write!(f, "invalid static texture: {context}") } @@ -1454,6 +1469,10 @@ pub struct VulkanSmokeRenderer { pub(super) readback_enabled: bool, pub(super) camera: VulkanStaticCamera, pub(super) frame_uniforms: VulkanFrameUniforms, + /// Dynamically sized point-light records submitted by the client. + pub(super) point_lights: Vec<VulkanPointLight>, + /// Whether `set_point_lights` has replaced legacy `frame_uniforms.point_light`. + pub(super) point_lights_explicit: bool, pub(super) frame_sync: Vec<VulkanFrameSync>, pub(super) images_in_flight: Vec<vk::Fence>, pub(super) current_frame: usize, diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs index b8049d3..89ca43a 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -9,9 +9,10 @@ use std::collections::BTreeMap; use super::{ color_subresource_range, create_depth_attachment, create_material_specular_buffer, - destroy_depth_attachment, VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, - VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe, - TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS, VULKAN_FRAME_UNIFORM_BYTES, + create_point_light_buffer, destroy_depth_attachment, VulkanAllocatedBuffer, + VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe, VulkanLogicalDeviceProbe, + VulkanSmokeRendererError, VulkanSwapchainProbe, TRIANGLE_FRAGMENT_SHADER_WORDS, + TRIANGLE_VERTEX_SHADER_WORDS, VULKAN_FRAME_UNIFORM_BYTES, VULKAN_MATERIAL_SPECULAR_RECORD_BYTES, }; @@ -33,6 +34,11 @@ pub(super) struct VulkanSwapchainResources { pub(super) frame_uniform_stride: u64, pub(super) material_specular_buffer: VulkanAllocatedBuffer, pub(super) material_specular_stride: u64, + pub(super) point_light_buffer: VulkanAllocatedBuffer, + pub(super) point_light_range: u64, + pub(super) point_light_stride: u64, + pub(super) point_light_capacity: usize, + pub(super) max_point_light_count: usize, /// Live graphics pipelines indexed by canonical backend-neutral state. pub(super) pipelines: BTreeMap<PipelineKey, vk::Pipeline>, pub(super) framebuffers: Vec<vk::Framebuffer>, @@ -73,6 +79,71 @@ pub(super) fn update_material_texture_descriptor( Ok(()) } +/// Grows the dynamic light SSBO when the scene adds lights beyond its current +/// capacity. The caller must idle the device before updating these descriptors. +pub(super) fn ensure_point_light_capacity( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + resources: &mut VulkanSwapchainResources, + needed: usize, +) -> Result<(), VulkanSmokeRendererError> { + if needed <= resources.point_light_capacity { + return Ok(()); + } + if needed > resources.max_point_light_count { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light count exceeds maxStorageBufferRange", + }); + } + let target = resources + .point_light_capacity + .saturating_mul(2) + .max(1) + .max(needed) + .min(resources.max_point_light_count); + let (new_buffer, new_range, new_stride, max_count) = + create_point_light_buffer(instance, device, target)?; + if max_count != resources.max_point_light_count { + unsafe { + device.device().destroy_buffer(new_buffer.buffer, None); + device.device().free_memory(new_buffer.memory, None); + } + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "point-light capacity changed for the active device", + }); + } + let info = vk::DescriptorBufferInfo::default() + .buffer(new_buffer.buffer) + .offset(0) + .range(new_range); + let writes = resources + .descriptor_sets + .iter() + .copied() + .map(|descriptor_set| { + vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(6) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) + .buffer_info(std::slice::from_ref(&info)) + }) + .collect::<Vec<_>>(); + // SAFETY: The caller waited for device idle before replacing a descriptor + // that submitted command buffers may reference. + unsafe { device.device().update_descriptor_sets(&writes, &[]) }; + let old_buffer = std::mem::replace(&mut resources.point_light_buffer, new_buffer); + resources.point_light_range = new_range; + resources.point_light_stride = new_stride; + resources.point_light_capacity = target; + // SAFETY: All prior submissions have completed and descriptors now point + // at the replacement allocation. + unsafe { + device.device().destroy_buffer(old_buffer.buffer, None); + device.device().free_memory(old_buffer.memory, None); + } + Ok(()) +} + struct PartialSwapchainResources { image_views: Vec<vk::ImageView>, readback_buffers: Vec<VulkanAllocatedBuffer>, @@ -87,6 +158,11 @@ struct PartialSwapchainResources { frame_uniform_stride: u64, material_specular_buffer: Option<VulkanAllocatedBuffer>, material_specular_stride: u64, + point_light_buffer: Option<VulkanAllocatedBuffer>, + point_light_range: u64, + point_light_stride: u64, + point_light_capacity: usize, + max_point_light_count: usize, pipelines: BTreeMap<PipelineKey, vk::Pipeline>, framebuffers: Vec<vk::Framebuffer>, command_buffers: Vec<vk::CommandBuffer>, @@ -133,6 +209,11 @@ pub(super) fn create_swapchain_resources( frame_uniform_stride: 0, material_specular_buffer: None, material_specular_stride: 0, + point_light_buffer: None, + point_light_range: 0, + point_light_stride: 0, + point_light_capacity: 0, + max_point_light_count: 0, pipelines: BTreeMap::new(), framebuffers: Vec::new(), command_buffers: Vec::new(), @@ -173,6 +254,20 @@ pub(super) fn create_swapchain_resources( }; partial.material_specular_stride = material_specular_stride; partial.material_specular_buffer = Some(material_specular_buffer); + let point_light_capacity = 1; + let (point_light_buffer, point_light_range, point_light_stride, max_point_light_count) = + match create_point_light_buffer(instance, device, point_light_capacity) { + Ok(buffer) => buffer, + Err(error) => { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(error); + } + }; + partial.point_light_range = point_light_range; + partial.point_light_stride = point_light_stride; + partial.point_light_capacity = point_light_capacity; + partial.max_point_light_count = max_point_light_count; + partial.point_light_buffer = Some(point_light_buffer); let ( depth_attachment, render_pass, @@ -199,6 +294,11 @@ pub(super) fn create_swapchain_resources( .material_specular_buffer .as_ref() .expect("material specular buffer is present before descriptor creation"), + partial + .point_light_buffer + .as_ref() + .expect("point-light buffer is present before descriptor creation"), + partial.point_light_range, ) { Ok(bundle) => bundle, Err(error) => { @@ -320,6 +420,13 @@ pub(super) fn create_swapchain_resources( .material_specular_buffer .expect("material specular buffer is present after successful swapchain setup"), material_specular_stride: partial.material_specular_stride, + point_light_buffer: partial + .point_light_buffer + .expect("point-light buffer is present after successful swapchain setup"), + point_light_range: partial.point_light_range, + point_light_stride: partial.point_light_stride, + point_light_capacity: partial.point_light_capacity, + max_point_light_count: partial.max_point_light_count, pipelines: partial.pipelines, framebuffers: partial.framebuffers, command_buffers: partial.command_buffers, @@ -349,6 +456,8 @@ fn create_swapchain_pipeline_bundle( depth_request: DepthStencilSupport, frame_uniform_buffer: &VulkanAllocatedBuffer, material_specular_buffer: &VulkanAllocatedBuffer, + point_light_buffer: &VulkanAllocatedBuffer, + point_light_range: u64, ) -> Result< ( VulkanDepthAttachment, @@ -377,6 +486,8 @@ fn create_swapchain_pipeline_bundle( texture_slot_indices, frame_uniform_buffer, material_specular_buffer, + point_light_buffer, + point_light_range, ) .inspect_err(|_| { // SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure. @@ -603,6 +714,8 @@ fn create_texture_descriptor_bundle( texture_slot_indices: &[usize], frame_uniform_buffer: &VulkanAllocatedBuffer, material_specular_buffer: &VulkanAllocatedBuffer, + point_light_buffer: &VulkanAllocatedBuffer, + point_light_range: u64, ) -> Result< ( vk::DescriptorSetLayout, @@ -615,6 +728,7 @@ fn create_texture_descriptor_bundle( const TEXTURES_PER_MATERIAL: usize = 4; const FRAME_BINDING: u32 = 4; const MATERIAL_SPECULAR_BINDING: u32 = 5; + const POINT_LIGHT_BINDING: u32 = 6; let mut bindings = (0..TEXTURES_PER_MATERIAL) .map(|binding| { vk::DescriptorSetLayoutBinding::default() @@ -638,6 +752,13 @@ fn create_texture_descriptor_bundle( .descriptor_count(1) .stage_flags(vk::ShaderStageFlags::VERTEX), ); + bindings.push( + vk::DescriptorSetLayoutBinding::default() + .binding(POINT_LIGHT_BINDING) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::VERTEX), + ); let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings); // SAFETY: The layout description is stack-owned and references no external memory. let layout = unsafe { @@ -676,10 +797,17 @@ fn create_texture_descriptor_bundle( let frame_pool_size = vk::DescriptorPoolSize::default() .ty(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC) .descriptor_count(material_count); - let material_specular_pool_size = vk::DescriptorPoolSize::default() + let storage_pool_size = vk::DescriptorPoolSize::default() .ty(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) - .descriptor_count(material_count); - let pool_sizes = [pool_size, frame_pool_size, material_specular_pool_size]; + .descriptor_count(material_count.checked_mul(2).ok_or_else(|| { + // SAFETY: The layout was created above on this device and is + // rolled back before reporting an impossible descriptor count. + unsafe { device.device().destroy_descriptor_set_layout(layout, None) }; + VulkanSmokeRendererError::InvariantViolation { + context: "dynamic storage descriptor count overflow", + } + })?); + let pool_sizes = [pool_size, frame_pool_size, storage_pool_size]; let pool_info = vk::DescriptorPoolCreateInfo::default() .pool_sizes(&pool_sizes) .max_sets(material_count); @@ -751,7 +879,11 @@ fn create_texture_descriptor_bundle( .buffer(material_specular_buffer.buffer) .offset(0) .range(VULKAN_MATERIAL_SPECULAR_RECORD_BYTES as u64); - let mut writes = Vec::with_capacity(descriptor_sets.len() * (TEXTURES_PER_MATERIAL + 2)); + let point_light_info = vk::DescriptorBufferInfo::default() + .buffer(point_light_buffer.buffer) + .offset(0) + .range(point_light_range); + let mut writes = Vec::with_capacity(descriptor_sets.len() * (TEXTURES_PER_MATERIAL + 3)); for (set_index, descriptor_set) in descriptor_sets.iter().copied().enumerate() { for binding in 0..TEXTURES_PER_MATERIAL { let image_info = &image_infos[set_index * TEXTURES_PER_MATERIAL + binding]; @@ -777,6 +909,13 @@ fn create_texture_descriptor_bundle( .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) .buffer_info(std::slice::from_ref(&material_specular_info)), ); + writes.push( + vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(POINT_LIGHT_BINDING) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) + .buffer_info(std::slice::from_ref(&point_light_info)), + ); } // SAFETY: Descriptor sets, sampler and image views are live; every texture upload completed its shader-read transition. unsafe { device.device().update_descriptor_sets(&writes, &[]) }; @@ -1114,6 +1253,12 @@ pub(super) fn destroy_swapchain_resources( device .device() .free_memory(resources.material_specular_buffer.memory, None); + device + .device() + .destroy_buffer(resources.point_light_buffer.buffer, None); + device + .device() + .free_memory(resources.point_light_buffer.memory, None); } } @@ -1177,5 +1322,9 @@ fn destroy_partial_swapchain_resources( device.device().destroy_buffer(buffer.buffer, None); device.device().free_memory(buffer.memory, None); } + if let Some(buffer) = partial.point_light_buffer { + device.device().destroy_buffer(buffer.buffer, None); + device.device().free_memory(buffer.memory, None); + } } } diff --git a/adapters/fparkan-render-vulkan/src/ffi/tests.rs b/adapters/fparkan-render-vulkan/src/ffi/tests.rs index 4ed61f7..b06bb26 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/tests.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs @@ -492,6 +492,51 @@ fn fragment_shader_dispatches_all_four_material_modes() { } #[test] +fn point_light_storage_payload_is_counted_and_packed_as_three_vec4s() { + let lights = [VulkanPointLight { + position: [1.0, 2.0, 3.0], + rgb: [4.0, 5.0, 6.0], + range: 7.0, + coefficients: [8.0, 9.0, 10.0], + active: true, + }]; + let bytes = super::resources::encode_point_lights(&lights).expect("point-light payload"); + assert_eq!( + bytes.len(), + VULKAN_POINT_LIGHT_HEADER_BYTES + VULKAN_POINT_LIGHT_RECORD_BYTES + ); + assert_eq!(u32::from_ne_bytes(bytes[0..4].try_into().unwrap()), 1); + let values = (0..12) + .map(|index| { + let offset = VULKAN_POINT_LIGHT_HEADER_BYTES + index * std::mem::size_of::<f32>(); + f32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap()) + }) + .collect::<Vec<_>>(); + assert_eq!( + values, + vec![1.0, 2.0, 3.0, 7.0, 4.0, 5.0, 6.0, 1.0, 8.0, 9.0, 10.0, 1.0] + ); + + let many = vec![VulkanPointLight::default(); 33]; + let bytes = super::resources::encode_point_lights(&many).expect("uncapped point-light payload"); + assert_eq!(u32::from_ne_bytes(bytes[0..4].try_into().unwrap()), 33); + assert_eq!( + bytes.len(), + VULKAN_POINT_LIGHT_HEADER_BYTES + 33 * VULKAN_POINT_LIGHT_RECORD_BYTES + ); +} + +#[test] +fn vertex_shader_sums_the_runtime_point_light_array() { + let vertex = include_str!("../../shaders/triangle.vert"); + assert!( + vertex.contains("layout(std430, set = 0, binding = 6) readonly buffer PointLightBuffer") + ); + assert!(vertex.contains("for (uint index = 0u; index < point_light_buffer.header.x; ++index)")); + assert!(!vertex.contains("if (point_rgb_active.w")); +} + +#[test] fn native_specular_reference_uses_squared_power_and_total_overflow_curve() { fn reference_power(cosine: f32, power: u8) -> f32 { if power == 0 { diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index a79220a..7ef546f 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -16,9 +16,9 @@ use fparkan_nres::{decode as decode_nres, ReadProfile}; pub use fparkan_nres::{NresDocument, NresError}; use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy, ResourceName}; use fparkan_prototype::{ - EffectivePrototype, PrototypeGeometry, PrototypeGraph, PrototypeGraphEdge, - PrototypeGraphFailure, PrototypeGraphNodeKind, PrototypeGraphProvenance, PrototypeGraphReport, - PrototypeGraphRequiredness, + decode_bas_geometry, BasGeometry, EffectivePrototype, PrototypeGeometry, PrototypeGraph, + PrototypeGraphEdge, PrototypeGraphFailure, PrototypeGraphNodeKind, PrototypeGraphProvenance, + PrototypeGraphReport, PrototypeGraphRequiredness, }; use fparkan_resource::{CachedResourceRepository, ResourceError, ResourceKey, ResourceRepository}; pub use fparkan_terrain::{TerrainError, TerrainWorld}; @@ -901,14 +901,117 @@ pub struct PreparedVisual { pub struct PreparedComponentControl { /// CTLD resource that supplied these bindings. pub source: ResourceKey, + /// Number of nodes in the component model this control targets. + pub model_node_count: usize, /// Enabled source rows in their original order, including duplicate nodes. pub bindings: Vec<PreparedControlNodeBinding>, + /// Every raw 36-byte animation row in source order. The typed controller + /// map refers to these original row indices, including disabled rows. + pub timeline_rows: Vec<PreparedControlTimelineRow>, + /// Parsed typed records retained for the native controller evaluator. + pub typed_records: Vec<PreparedControlTypedRecord>, + /// Explicit NDP sidecar and its validated rows, when resolved. + pub node_data: Option<PreparedControlNodeDataTable>, /// Parsed native effect rows in this component's control resource. pub effect_section: PreparedControlEffectSection, /// Component-local points selected for parsed effect rows, when present. pub point_list: Option<PreparedControlPointList>, } +/// Component-local BAS geometry loaded from an explicitly referenced BAS +/// resource. This remains aligned with each component instance, not the shared +/// model cache. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedComponentBas { + /// BAS resource that supplied this geometry. + pub source: ResourceKey, + /// Decoded BAS point sections. + pub geometry: BasGeometry, +} + +/// Raw and decoded fields from one 36-byte CTLD animation row. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PreparedControlTimelineRow { + /// Original row index, used by typed-record map values. + pub row_index: usize, + /// Target node index in the component model. + pub node_index: usize, + /// First animation endpoint. + pub frame_a: f32, + /// Second animation endpoint. + pub frame_b: f32, + /// Initial blend written by native Control during setup. + pub initial_blend: f32, + /// Native auxiliary words at row offsets +0x10, +0x14, +0x18, and +0x1C. + pub raw_parameters: [u32; 4], + /// Raw native row flags at +0x20. + pub flags: u32, +} + +/// One parsed 0xB0-byte typed-record body and its following value/string data. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedControlTypedRecord { + /// Native record tag at body offset 0. + pub tag: u32, + /// All 44 raw little-endian words from the fixed 0xB0-byte body. + pub fixed_words: [u32; 44], + /// Raw values following the fixed body; tag-specific parsing is deferred. + pub values: Vec<u32>, + /// Raw bytes following the value array, excluding the required trailing NUL. + pub string_bytes: Vec<u8>, + /// Native tag-21 query provider resolved from the explicit NDP sidecar. + /// Other tags use `NotApplicable`. + pub gate_provider: PreparedControlGateProvider, +} + +/// Initial tag-21 query provider resolution for one typed controller record. +#[derive(Clone, Debug, PartialEq)] +pub enum PreparedControlGateProvider { + /// The record is not a tag-21 controller. + NotApplicable, + /// Its raw key selects a validated NDP row. Native Control initializes + /// this type-1 query value to 1.0 before runtime state changes. + Resolved { + key: u32, + ndp_source: ResourceKey, + row_count: usize, + initial_type1_value: f32, + }, + /// No NDP reference was present, or its referenced archive/entry is absent. + MissingResource { + key: u32, + ndp_source: Option<ResourceKey>, + }, + /// The key is not a row in the validated NDP table. + KeyOutOfRange { + key: u32, + ndp_source: ResourceKey, + row_count: usize, + }, +} + +/// Parsed NDP sidecar used as the native Control query-provider table. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedControlNodeDataTable { + /// Explicit NDP resource paired with the component prototype. + pub source: ResourceKey, + /// Provider rows in their native table order. + pub rows: Vec<PreparedControlNodeDataRow>, +} + +/// One validated 76-byte NDP sidecar row. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedControlNodeDataRow { + /// Raw native flag bits. + pub raw_flags: u32, + /// Maximum volume value at row offset +4. + pub max_volume: f32, + /// Mobility-per-volume value at row offset +8. + pub mobility_per_volume: f32, + /// Effect resource pair at row offset +12. + pub effect: PreparedControlResourcePair, +} + /// Decoded point records selected by a component's Control configuration. #[derive(Clone, Debug, PartialEq)] pub struct PreparedControlPointList { @@ -994,6 +1097,382 @@ pub struct PreparedControlNodeBinding { pub flags: u32, } +/// Per-component instance state for native tag-21 control animation. +/// +/// Keep one value per model component instance; controls on shared models can +/// have independent clocks and gate samples. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ControlInstanceState { + initialized: bool, + node_times: Vec<Option<f32>>, + tag21_tracks: Vec<ControlTag21InstanceState>, +} + +#[derive(Clone, Debug, PartialEq)] +struct ControlTag21InstanceState { + phase: f32, + segment_start_ms: f32, + segment_end_ms: f32, + reciprocal_duration: f32, + initialized: bool, + maps: Vec<ControlTag21MapInstanceState>, +} + +#[derive(Clone, Debug, PartialEq)] +struct ControlTag21MapInstanceState { + row_index: usize, + previous: f32, + next: f32, + current: f32, +} + +impl Default for ControlTag21InstanceState { + fn default() -> Self { + Self { + phase: 0.0, + segment_start_ms: 0.0, + segment_end_ms: 0.0, + reciprocal_duration: 1.0, + initialized: false, + maps: Vec::new(), + } + } +} + +/// Input or metadata prevented a safe native control sample. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ControlSamplingError { + /// A state object was reused with a different model node count. + StateNodeCountMismatch { expected: usize, actual: usize }, + /// A state object was reused with a different number of tag-21 records. + StateTimelineCountMismatch { expected: usize, actual: usize }, + /// The caller did not provide one query-provider value per tag-21 record. + GateProviderCountMismatch { expected: usize, actual: usize }, + /// A tag-21 record has no corresponding NDP provider resource. + MissingGateProvider { record_index: usize, key: u32 }, + /// A tag-21 query key does not select a row in its NDP table. + GateProviderKeyOutOfRange { + record_index: usize, + key: u32, + row_count: usize, + }, + /// A resolved provider lacks a finite current type-1 value. + MissingGateProviderValue { record_index: usize, key: u32 }, + /// The record uses a tag-21 mode whose native selector is not yet decoded. + UnsupportedTag21Mode { record_index: usize, packed: u32 }, + /// A tag-21 record did not retain a valid source-row map index. + InvalidTag21MapIndex { + record_index: usize, + map_index: usize, + }, + /// A mapped source row reuses the previous typed row's current value. + /// + /// This native flag is currently rejected so callers can use their static + /// fallback instead of silently sampling the wrong row value. + UnsupportedPreviousRowMapping { row_index: usize }, + /// A mapped raw source row has an invalid rate at +0x18. + InvalidTransitionRate { row_index: usize }, + /// A mapped raw source row uses external variable selectors at +0x10/+0x14. + UnsupportedVariableMapping { row_index: usize }, + /// The controller computation produced a non-finite sample. + NonFiniteSample { record_index: usize }, +} + +/// Samples per-node animation times using the decoded Control timeline. +/// +/// `native_clock_ms` is the absolute u32 millisecond clock passed to native +/// Control::Update. `gate_values` carries one provider type-1 float per tag-21 +/// record, in source order. Native query 0x21820 returns 1 when that float is +/// zero, and the tag-21 update stops when that query returns 1; thus each +/// record updates when its provider value is nonzero. `None` values are +/// rejected explicitly. Values are not blend values. The state must be private +/// to one component instance and retained between calls. Rows and typed maps +/// are processed in source order, so later rows targeting one node win as in +/// native Control. +pub fn sample_control_node_times( + control: &PreparedComponentControl, + native_clock_ms: u32, + gate_values: &[Option<f32>], + state: &mut ControlInstanceState, +) -> Result<Vec<Option<f32>>, ControlSamplingError> { + let node_count = control.model_node_count; + let tag21_records: Vec<&PreparedControlTypedRecord> = control + .typed_records + .iter() + .filter(|record| record.tag == 21) + .collect(); + if gate_values.len() != tag21_records.len() { + return Err(ControlSamplingError::GateProviderCountMismatch { + expected: tag21_records.len(), + actual: gate_values.len(), + }); + } + + if state.initialized { + if state.node_times.len() != node_count { + return Err(ControlSamplingError::StateNodeCountMismatch { + expected: node_count, + actual: state.node_times.len(), + }); + } + if state.tag21_tracks.len() != tag21_records.len() { + return Err(ControlSamplingError::StateTimelineCountMismatch { + expected: tag21_records.len(), + actual: state.tag21_tracks.len(), + }); + } + } else { + state.node_times = vec![Some(0.0); node_count]; + for binding in &control.bindings { + if binding.flags & 4 == 0 && binding.node_index < node_count { + let blend = native_control_blend(binding.initial_blend, binding.flags); + state.node_times[binding.node_index] = + Some((1.0 - blend) * binding.frame_a + blend * binding.frame_b); + } + } + state.tag21_tracks = tag21_records + .iter() + .enumerate() + .map(|(record_index, record)| { + let mut track = ControlTag21InstanceState::default(); + for map_index in &record.values { + let row_index = usize::try_from(*map_index).unwrap_or(usize::MAX); + let Some(row) = control.timeline_rows.get(row_index) else { + return Err(ControlSamplingError::InvalidTag21MapIndex { + record_index, + map_index: row_index, + }); + }; + track.maps.push(ControlTag21MapInstanceState { + row_index, + previous: row.initial_blend, + next: row.initial_blend, + current: row.initial_blend, + }); + } + Ok(track) + }) + .collect::<Result<Vec<_>, _>>()?; + state.initialized = true; + } + + let now_ms = native_clock_ms as f32; + for (record_index, record) in tag21_records.iter().enumerate() { + let packed = record.fixed_words[2]; + let raw_flags = record.fixed_words[6]; + let controller_flags = if raw_flags == u32::MAX { 5 } else { raw_flags }; + if packed != 0 || controller_flags != 5 { + return Err(ControlSamplingError::UnsupportedTag21Mode { + record_index, + packed, + }); + } + let coefficient = f32::from_bits(record.fixed_words[9]); + let unused_coefficient = f32::from_bits(record.fixed_words[10]); + if !coefficient.is_finite() || !unused_coefficient.is_finite() { + return Err(ControlSamplingError::NonFiniteSample { record_index }); + } + + let key = record.fixed_words[1]; + match &record.gate_provider { + PreparedControlGateProvider::Resolved { + key: provider_key, + row_count, + .. + } if *provider_key == key && usize::try_from(key).is_ok_and(|key| key < *row_count) => { + } + PreparedControlGateProvider::KeyOutOfRange { + key: provider_key, + row_count, + .. + } if *provider_key == key => { + return Err(ControlSamplingError::GateProviderKeyOutOfRange { + record_index, + key, + row_count: *row_count, + }); + } + PreparedControlGateProvider::MissingResource { + key: provider_key, .. + } if *provider_key == key => { + return Err(ControlSamplingError::MissingGateProvider { record_index, key }); + } + _ => return Err(ControlSamplingError::MissingGateProvider { record_index, key }), + } + let provider_value = gate_values[record_index] + .ok_or(ControlSamplingError::MissingGateProviderValue { record_index, key })?; + if !provider_value.is_finite() { + return Err(ControlSamplingError::MissingGateProviderValue { record_index, key }); + } + + let track = &mut state.tag21_tracks[record_index]; + let mut catch_up_steps = 0_usize; + while !track.initialized || now_ms > track.segment_end_ms { + if track.initialized { + // 2D260 calls 21A30 before opening the next interval. Since + // this loop runs only after `clock > end`, that sample has + // reached every typed map's next value. + for mapping in &mut track.maps { + mapping.current = mapping.next; + } + } + if provider_value == 0.0 { + // A closed native gate keeps the current value and rebases the + // next transition to this absolute sample time. + for mapping in &mut track.maps { + mapping.previous = mapping.current; + mapping.next = mapping.current; + } + track.segment_start_ms = now_ms; + track.segment_end_ms = now_ms; + track.reciprocal_duration = 1.0; + track.initialized = true; + break; + } + + let direction = match controller_flags & 3 { + 1 => 1.0, + 2 => -1.0, + _ => 0.0, + }; + let mut next_phase = track.phase + coefficient * 0.45 * direction; + if controller_flags & 0x0C == 4 { + next_phase = wrap_control_value_once(next_phase); + } else if controller_flags & 0x0C == 8 { + next_phase = next_phase.clamp(0.0, 1.0); + } + let mut duration_ms = 0.0_f32; + for mapping in &mut track.maps { + let row = &control.timeline_rows[mapping.row_index]; + if row.flags & 0x40 != 0 { + return Err(ControlSamplingError::UnsupportedPreviousRowMapping { + row_index: mapping.row_index, + }); + } + if row.raw_parameters[0] != u32::MAX || row.raw_parameters[1] != u32::MAX { + return Err(ControlSamplingError::UnsupportedVariableMapping { + row_index: mapping.row_index, + }); + } + let rate = f32::from_bits(row.raw_parameters[2]); + if !rate.is_finite() || rate <= 0.0 { + return Err(ControlSamplingError::InvalidTransitionRate { + row_index: mapping.row_index, + }); + } + mapping.previous = mapping.current; + mapping.next = next_phase; + let direct_distance = (mapping.next - mapping.previous).abs(); + let distance = if row.flags & 1 != 0 { + shortest_wrapped_distance(mapping.previous, mapping.next) + } else { + direct_distance + }; + let transition_speed = coefficient.abs() * rate; + let candidate_duration = if transition_speed > 0.0 { + 1000.0 * distance / transition_speed + } else { + 100.0 + }; + if !candidate_duration.is_finite() { + return Err(ControlSamplingError::NonFiniteSample { record_index }); + } + duration_ms = duration_ms.max(candidate_duration); + } + + // Native Control only substitutes 100 ms when the proposed + // interval is shorter than 1 ms; ordinary short transitions stay + // short (for example 22.5 ms at rate 20). + if duration_ms < 1.0 { + duration_ms = 100.0; + } + track.segment_start_ms = track.segment_end_ms; + track.phase = next_phase; + track.segment_end_ms = track.segment_start_ms + duration_ms; + let actual_duration = track.segment_end_ms - track.segment_start_ms; + track.reciprocal_duration = if actual_duration > 0.0 { + 1.0 / actual_duration + } else { + 1.0 + }; + track.initialized = true; + catch_up_steps += 1; + if catch_up_steps > 100_000 { + // shortcut: cap catch-up at 100,000 transitions; replace with a bounded + // fast-forward if valid long clock stalls must reproduce every transition. + return Err(ControlSamplingError::NonFiniteSample { record_index }); + } + } + + let duration = track.segment_end_ms - track.segment_start_ms; + let alpha = if duration > 0.0 { + ((now_ms - track.segment_start_ms) * track.reciprocal_duration).clamp(0.0, 1.0) + } else { + 1.0 + }; + for mapping in &mut track.maps { + let row = &control.timeline_rows[mapping.row_index]; + if row.flags & 4 != 0 || row.node_index >= node_count { + continue; + } + let mut delta = mapping.next - mapping.previous; + if row.flags & 1 != 0 { + if delta > 0.5 { + delta -= 1.0; + } else if delta < -0.5 { + delta += 1.0; + } + mapping.current = wrap_control_value_once(mapping.previous + delta * alpha); + } else { + mapping.current = (mapping.previous + delta * alpha).clamp(0.0, 1.0); + } + if !mapping.current.is_finite() { + return Err(ControlSamplingError::NonFiniteSample { record_index }); + } + let blend = native_control_blend(mapping.current, row.flags); + let time = (1.0 - blend) * row.frame_a + blend * row.frame_b; + if !time.is_finite() || time < 0.0 { + return Err(ControlSamplingError::NonFiniteSample { record_index }); + } + state.node_times[row.node_index] = Some(time); + } + } + + Ok(state.node_times.clone()) +} + +fn shortest_wrapped_distance(previous: f32, next: f32) -> f32 { + let direct = (next - previous).abs(); + direct.min((1.0 - direct).abs()) +} + +fn wrap_control_value_once(mut value: f32) -> f32 { + if value > 1.0 { + value -= 1.0; + } + if value < 0.0 { + value += 1.0; + } + value +} + +fn native_control_blend(mut blend: f32, flags: u32) -> f32 { + if flags & 1 != 0 { + if blend > 1.0 { + blend -= 1.0; + } + if blend < 0.0 { + blend += 1.0; + } + } else { + blend = blend.clamp(0.0, 1.0); + } + if flags & 2 != 0 { + blend = 1.0 - blend; + } + blend +} + /// CPU-side validated model ready for a renderer upload path. #[derive(Clone, Debug, PartialEq)] pub struct PreparedModel { @@ -1102,6 +1581,10 @@ pub struct MissionAssets { /// component list. `None` means no CTLD pose override is available because /// there is no explicit reference or its referenced archive/entry is absent. pub object_component_controls: Vec<Vec<Option<PreparedComponentControl>>>, + /// Optional component-local BAS geometry aligned with each object's visual + /// component list. `None` means there is no explicit BAS reference or its + /// archive/entry is absent. + pub object_component_bas: Vec<Vec<Option<PreparedComponentBas>>>, } impl MissionAssets { @@ -1539,7 +2022,15 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( } let mut prototype_component_controls = Vec::with_capacity(prototypes.len()); + let mut prototype_component_bas = Vec::with_capacity(prototypes.len()); for (prototype_index, prototype) in prototypes.iter().enumerate() { + let bas = match prototype.bas_resource.clone() { + Some(source) => { + prepare_component_bas_from_resource(repository, source, prototype_index)? + } + None => None, + }; + prototype_component_bas.push(bas); let Some(source) = prototype.control_resource.clone() else { prototype_component_controls.push(None); continue; @@ -1563,6 +2054,7 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( repository, source, prototype.point_resource.clone(), + prototype.node_data_resource.clone(), model.validated.node_count, prototype_index, )?; @@ -1571,6 +2063,7 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( let mut object_visuals = Vec::with_capacity(root_prototype_spans.len()); let mut object_component_controls = Vec::with_capacity(root_prototype_spans.len()); + let mut object_component_bas = Vec::with_capacity(root_prototype_spans.len()); for (root_index, span) in root_prototype_spans.iter().enumerate() { if span.start > span.end || span.end > prototype_visual_ids.len() { return Err(AssetError::InvalidPrototype(format!( @@ -1579,6 +2072,7 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( } let mut ids = Vec::new(); let mut controls = Vec::new(); + let mut bases = Vec::new(); for index in span.clone() { let visual_id = prototype_visual_ids[index]; // A unit DAT is an instance tree. Reusing one prepared visual @@ -1586,9 +2080,11 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( // identical cannons on w_m_wlk2). ids.push(visual_id); controls.push(prototype_component_controls[index].clone()); + bases.push(prototype_component_bas[index].clone()); } object_visuals.push(ids); object_component_controls.push(controls); + object_component_bas.push(bases); } let assets = MissionAssets { @@ -1599,6 +2095,7 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( visuals, object_visuals, object_component_controls, + object_component_bas, }; let report = AssetPreparationReport { model_count: assets.models.len(), @@ -2861,6 +3358,7 @@ fn prepare_component_control_from_resource<R: ResourceRepository>( repository: &R, source: ResourceKey, point_source: Option<ResourceKey>, + node_data_source: Option<ResourceKey>, model_node_count: usize, prototype_index: usize, ) -> Result<Option<PreparedComponentControl>, AssetError> { @@ -2869,6 +3367,19 @@ fn prepare_component_control_from_resource<R: ResourceRepository>( }; let mut control = decode_component_control_payload(&payload, source, model_node_count, prototype_index)?; + let node_data_payload = match node_data_source.as_ref() { + Some(source) => read_optional_key(repository, source, Some("component node data"))?, + None => None, + }; + control.node_data = match (node_data_source.as_ref(), node_data_payload) { + (Some(source), Some(payload)) => Some(decode_component_node_data_table( + &payload, + source.clone(), + prototype_index, + )?), + _ => None, + }; + resolve_control_gate_providers(&mut control, node_data_source.as_ref()); let has_effect_rows = matches!( &control.effect_section, PreparedControlEffectSection::Parsed(rows) if !rows.is_empty() @@ -2889,6 +3400,113 @@ fn prepare_component_control_from_resource<R: ResourceRepository>( Ok(Some(control)) } +fn prepare_component_bas_from_resource<R: ResourceRepository>( + repository: &R, + source: ResourceKey, + prototype_index: usize, +) -> Result<Option<PreparedComponentBas>, AssetError> { + let Some(payload) = read_optional_key(repository, &source, Some("component BAS geometry"))? + else { + return Ok(None); + }; + let geometry = decode_bas_geometry(&payload).map_err(|error| { + AssetError::InvalidPrototype(format!( + "prototype {prototype_index} BAS geometry {}:{}: {error}", + source.archive.as_str(), + String::from_utf8_lossy(&source.name.0), + )) + })?; + Ok(Some(PreparedComponentBas { source, geometry })) +} + +fn decode_component_node_data_table( + payload: &[u8], + source: ResourceKey, + prototype_index: usize, +) -> Result<PreparedControlNodeDataTable, AssetError> { + const HEADER_BYTES: usize = 4; + const ROW_BYTES: usize = 76; + const ROW_LIMIT: usize = 65_536; + + let invalid = |message: &str| { + AssetError::InvalidPrototype(format!( + "prototype {prototype_index} node data {}:{}: {message}", + source.archive.as_str(), + String::from_utf8_lossy(&source.name.0), + )) + }; + let row_count = + usize::try_from(control_u32_at(payload, 0).ok_or_else(|| invalid("missing row count"))?) + .map_err(|_| invalid("row count overflows usize"))?; + if row_count > ROW_LIMIT { + return Err(invalid("row count exceeds the supported limit")); + } + let table_bytes = row_count + .checked_mul(ROW_BYTES) + .ok_or_else(|| invalid("row table byte length overflows"))?; + let expected_len = HEADER_BYTES + .checked_add(table_bytes) + .ok_or_else(|| invalid("row table end offset overflows"))?; + if payload.len() != expected_len { + return Err(invalid( + "payload length does not match its declared 76-byte row count", + )); + } + + let mut rows = Vec::with_capacity(row_count); + for row_index in 0..row_count { + let start = HEADER_BYTES + row_index * ROW_BYTES; + let word = |offset: usize| { + control_u32_at(payload, start + offset).expect("validated 76-byte NDP row") + }; + let mut archive = [0; 32]; + let mut name = [0; 32]; + archive.copy_from_slice(&payload[start + 12..start + 44]); + name.copy_from_slice(&payload[start + 44..start + 76]); + rows.push(PreparedControlNodeDataRow { + raw_flags: word(0), + max_volume: f32::from_bits(word(4)), + mobility_per_volume: f32::from_bits(word(8)), + effect: PreparedControlResourcePair { archive, name }, + }); + } + Ok(PreparedControlNodeDataTable { source, rows }) +} + +fn resolve_control_gate_providers( + control: &mut PreparedComponentControl, + node_data_source: Option<&ResourceKey>, +) { + const INITIAL_TYPE1_VALUE: f32 = 1.0; + let row_count = control.node_data.as_ref().map(|table| table.rows.len()); + for record in control + .typed_records + .iter_mut() + .filter(|record| record.tag == 21) + { + let key = record.fixed_words[1]; + record.gate_provider = match (control.node_data.as_ref(), row_count) { + (Some(table), Some(count)) if usize::try_from(key).is_ok_and(|key| key < count) => { + PreparedControlGateProvider::Resolved { + key, + ndp_source: table.source.clone(), + row_count: count, + initial_type1_value: INITIAL_TYPE1_VALUE, + } + } + (Some(table), Some(count)) => PreparedControlGateProvider::KeyOutOfRange { + key, + ndp_source: table.source.clone(), + row_count: count, + }, + _ => PreparedControlGateProvider::MissingResource { + key, + ndp_source: node_data_source.cloned(), + }, + }; + } +} + fn decode_component_point_list( payload: &[u8], source: ResourceKey, @@ -2981,7 +3599,7 @@ fn control_i32_at(payload: &[u8], offset: usize) -> Option<i32> { )) } -const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] = &[1, 8, 9, 12, 13, 15, 19, 21, 25, 26]; +const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] = &[1, 2, 5, 8, 9, 12, 13, 15, 19, 21, 25, 26]; const CONTROL_TYPED_RECORD_FIXED_BYTES: usize = 0xB0; const CONTROL_TYPED_RECORD_COUNT_OFFSET: usize = 0xAC; const CONTROL_TYPED_RECORD_LIMIT: usize = 4096; @@ -2992,24 +3610,32 @@ const CONTROL_EFFECT_ROW_LIMIT: usize = 65_536; const CONTROL_EFFECT_GROUP_HEADER_BYTES: usize = 0x54; const CONTROL_EFFECT_ROW_BYTES: usize = 100; +struct DecodedComponentEffectSection { + section: PreparedControlEffectSection, + typed_records: Vec<PreparedControlTypedRecord>, +} + fn decode_component_effect_section<F>( payload: &[u8], mut cursor: usize, typed_record_count: usize, effect_group_count: usize, invalid: &F, -) -> Result<PreparedControlEffectSection, AssetError> +) -> Result<DecodedComponentEffectSection, AssetError> where F: Fn(&str) -> AssetError, { if typed_record_count == 0 && effect_group_count == 0 { - return Ok(if cursor == payload.len() { - PreparedControlEffectSection::Absent - } else { - PreparedControlEffectSection::Unsupported { - typed_record_index: None, - typed_record_tag: None, - } + return Ok(DecodedComponentEffectSection { + section: if cursor == payload.len() { + PreparedControlEffectSection::Absent + } else { + PreparedControlEffectSection::Unsupported { + typed_record_index: None, + typed_record_tag: None, + } + }, + typed_records: Vec::new(), }); } if typed_record_count > CONTROL_TYPED_RECORD_LIMIT { @@ -3019,13 +3645,17 @@ where return Err(invalid("effect group count exceeds the supported limit")); } + let mut typed_records = Vec::with_capacity(typed_record_count); for record_index in 0..typed_record_count { let tag = control_u32_at(payload, cursor) .ok_or_else(|| invalid("typed record is missing its tag"))?; if !CONTROL_TYPED_COMMON_PARSER_TAGS.contains(&tag) { - return Ok(PreparedControlEffectSection::Unsupported { - typed_record_index: Some(record_index), - typed_record_tag: Some(tag), + return Ok(DecodedComponentEffectSection { + section: PreparedControlEffectSection::Unsupported { + typed_record_index: Some(record_index), + typed_record_tag: Some(tag), + }, + typed_records, }); } @@ -3061,41 +3691,69 @@ where // Native parsing returns immediately for length zero. For UINT32_MAX, // its signed length+1 wraps to zero, also consuming no string bytes. - let string_bytes = if string_length == 0 || string_length == u32::MAX { - 0 + let (string_end, string_content_end) = if string_length == 0 || string_length == u32::MAX { + (after_length, after_length) } else { - usize::try_from(string_length) + let string_bytes = usize::try_from(string_length) .ok() .and_then(|length| length.checked_add(1)) - .ok_or_else(|| invalid("typed record string length overflows"))? + .ok_or_else(|| invalid("typed record string length overflows"))?; + if string_bytes > CONTROL_TYPED_STRING_LIMIT { + return Err(invalid("typed record string exceeds the supported limit")); + } + let string_end = after_length + .checked_add(string_bytes) + .ok_or_else(|| invalid("typed record string end overflows"))?; + if string_end > payload.len() { + return Err(invalid("typed record string extends past the payload")); + } + if payload[string_end - 1] != 0 { + return Err(invalid("typed record string is missing its trailing NUL")); + } + (string_end, string_end - 1) }; - if string_bytes == 0 { - cursor = after_length; - continue; - } - if string_bytes > CONTROL_TYPED_STRING_LIMIT { - return Err(invalid("typed record string exceeds the supported limit")); - } - let string_end = after_length - .checked_add(string_bytes) - .ok_or_else(|| invalid("typed record string end overflows"))?; - if string_end > payload.len() { - return Err(invalid("typed record string extends past the payload")); - } - if payload[string_end - 1] != 0 { - return Err(invalid("typed record string is missing its trailing NUL")); + let mut fixed_words = [0_u32; 44]; + for (word_index, word) in fixed_words.iter_mut().enumerate() { + *word = control_u32_at(payload, cursor + word_index * 4) + .expect("typed record fixed header was checked"); } + let values = (0..value_count) + .map(|index| { + control_u32_at( + payload, + cursor + CONTROL_TYPED_RECORD_FIXED_BYTES + index * 4, + ) + .expect("typed record values were checked") + }) + .collect(); + typed_records.push(PreparedControlTypedRecord { + tag, + fixed_words, + values, + string_bytes: payload[after_length..string_content_end].to_vec(), + gate_provider: if tag == 21 { + PreparedControlGateProvider::MissingResource { + key: fixed_words[1], + ndp_source: None, + } + } else { + PreparedControlGateProvider::NotApplicable + }, + }); cursor = string_end; } if effect_group_count == 0 { - return Ok(if cursor == payload.len() { - PreparedControlEffectSection::Parsed(Vec::new()) - } else { - PreparedControlEffectSection::Unsupported { - typed_record_index: None, - typed_record_tag: None, - } + return Ok(DecodedComponentEffectSection { + section: if cursor == payload.len() { + PreparedControlEffectSection::Parsed(Vec::new()) + } else { + PreparedControlEffectSection::Unsupported { + typed_record_index: None, + typed_record_tag: None, + } + }, + typed_records, }); } @@ -3165,12 +3823,18 @@ where } if cursor != payload.len() { - return Ok(PreparedControlEffectSection::Unsupported { - typed_record_index: None, - typed_record_tag: None, + return Ok(DecodedComponentEffectSection { + section: PreparedControlEffectSection::Unsupported { + typed_record_index: None, + typed_record_tag: None, + }, + typed_records, }); } - Ok(PreparedControlEffectSection::Parsed(effect_rows)) + Ok(DecodedComponentEffectSection { + section: PreparedControlEffectSection::Parsed(effect_rows), + typed_records, + }) } fn control_u32_at(payload: &[u8], offset: usize) -> Option<u32> { @@ -3250,22 +3914,38 @@ fn decode_component_control_payload( } let mut bindings = Vec::with_capacity(row_count); + let mut timeline_rows = Vec::with_capacity(row_count); for row_index in 0..row_count { let row_start = row_start + row_index * ROW_BYTES; let row = &payload[row_start..row_start + ROW_BYTES]; let flags = u32::from_le_bytes(row[0x20..0x24].try_into().expect("row flags")); - if flags & 4 != 0 { - continue; - } let node_index = u32::from_le_bytes(row[0..4].try_into().expect("row node index")) as usize; - if node_index >= model_node_count { - return Err(invalid("enabled binding node index is outside the model")); - } let frame_a = f32::from_bits(u32::from_le_bytes(row[4..8].try_into().expect("frame A"))); let frame_b = f32::from_bits(u32::from_le_bytes(row[8..12].try_into().expect("frame B"))); let initial_blend = f32::from_bits(u32::from_le_bytes( row[12..16].try_into().expect("initial blend"), )); + let raw_parameters = [ + u32::from_le_bytes(row[0x10..0x14].try_into().expect("row parameter +0x10")), + u32::from_le_bytes(row[0x14..0x18].try_into().expect("row parameter +0x14")), + u32::from_le_bytes(row[0x18..0x1C].try_into().expect("row parameter +0x18")), + u32::from_le_bytes(row[0x1C..0x20].try_into().expect("row parameter +0x1C")), + ]; + timeline_rows.push(PreparedControlTimelineRow { + row_index, + node_index, + frame_a, + frame_b, + initial_blend, + raw_parameters, + flags, + }); + if flags & 4 != 0 { + continue; + } + if node_index >= model_node_count { + return Err(invalid("enabled binding node index is outside the model")); + } if !frame_a.is_finite() || !frame_b.is_finite() || !initial_blend.is_finite() @@ -3285,7 +3965,7 @@ fn decode_component_control_payload( }); } - let effect_section = decode_component_effect_section( + let decoded_effect = decode_component_effect_section( payload, row_end, typed_record_count, @@ -3295,8 +3975,12 @@ fn decode_component_control_payload( Ok(PreparedComponentControl { source, + model_node_count, bindings, - effect_section, + timeline_rows, + typed_records: decoded_effect.typed_records, + node_data: None, + effect_section: decoded_effect.section, point_list: None, }) } @@ -3402,6 +4086,164 @@ mod tests { use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs}; use std::path::PathBuf; + // Preserve the authored CTLD fixture bits; this is not the `f32` value of tau. + const AUTHORED_TAG21_RAW_PARAMETER_3: f32 = f32::from_bits(0x40C8_F5C3); + + fn tag21_fullcycle_control() -> PreparedComponentControl { + let source = ResourceKey { + archive: parse_path("parts.rlb").expect("archive"), + name: resource_name(b"u_all_def_a_01.ctl"), + type_id: None, + }; + let mut payload = component_control_payload(&[(1, 0.0, 4.0, 0.0, 1)]); + let row_start = 5 * 4 + 108; + payload[row_start + 0x10..row_start + 0x14].copy_from_slice(&u32::MAX.to_le_bytes()); + payload[row_start + 0x14..row_start + 0x18].copy_from_slice(&u32::MAX.to_le_bytes()); + payload[row_start + 0x18..row_start + 0x1C] + .copy_from_slice(&0.5_f32.to_bits().to_le_bytes()); + payload[row_start + 0x1C..row_start + 0x20] + .copy_from_slice(&AUTHORED_TAG21_RAW_PARAMETER_3.to_bits().to_le_bytes()); + payload[12..16].copy_from_slice(&1_u32.to_le_bytes()); + + let typed_start = payload.len(); + let mut fixed = [0_u8; CONTROL_TYPED_RECORD_FIXED_BYTES]; + fixed[0..4].copy_from_slice(&21_u32.to_le_bytes()); + fixed[8..12].copy_from_slice(&0_u32.to_le_bytes()); + fixed[0x18..0x1C].copy_from_slice(&u32::MAX.to_le_bytes()); + fixed[0x24..0x28].copy_from_slice(&1.0_f32.to_bits().to_le_bytes()); + fixed[0x28..0x2C].copy_from_slice(&1.0_f32.to_bits().to_le_bytes()); + fixed[CONTROL_TYPED_RECORD_COUNT_OFFSET..CONTROL_TYPED_RECORD_COUNT_OFFSET + 4] + .copy_from_slice(&1_u32.to_le_bytes()); + payload.extend_from_slice(&fixed); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + assert_eq!( + typed_start + CONTROL_TYPED_RECORD_FIXED_BYTES + 8, + payload.len() + ); + + let mut control = + decode_component_control_payload(&payload, source, 2, 0).expect("tag21 CTLD"); + control.typed_records[0].gate_provider = PreparedControlGateProvider::Resolved { + key: 0, + ndp_source: ResourceKey { + archive: parse_path("parts.rlb").expect("NDP archive"), + name: resource_name(b"u_all_def_a_01.ndp"), + type_id: None, + }, + row_count: 1, + initial_type1_value: 1.0, + }; + control + } + + #[test] + fn tag21_sampler_replays_authored_fullcycle_clock_and_gate() { + let control = tag21_fullcycle_control(); + assert_eq!(control.model_node_count, 2); + assert_eq!(control.typed_records.len(), 1); + assert_eq!(control.typed_records[0].tag, 21); + assert_eq!(control.typed_records[0].values, vec![0]); + assert_eq!( + f32::from_bits(control.timeline_rows[0].raw_parameters[2]), + 0.5 + ); + assert_eq!( + f32::from_bits(control.timeline_rows[0].raw_parameters[3]), + AUTHORED_TAG21_RAW_PARAMETER_3 + ); + + // Native Control receives 1.0 from the first default NDP provider row. + // Since 0x21820 tests for zero and its caller stops on return value 1, + // a nonzero provider value opens the tag-21 update path. + let default_provider_value = 1.0_f32; + let gate_values = [Some(default_provider_value)]; + let mut state = ControlInstanceState::default(); + let at_zero = sample_control_node_times(&control, 0, &gate_values, &mut state) + .expect("initial tag21 sample"); + assert_eq!(at_zero, vec![Some(0.0), Some(0.0)]); + + let at_quarter_step = sample_control_node_times(&control, 225, &gate_values, &mut state) + .expect("quarter-step tag21 sample"); + assert!((at_quarter_step[1].expect("node 1") - 0.45).abs() < 1.0e-5); + + let at_first_transition = + sample_control_node_times(&control, 900, &gate_values, &mut state) + .expect("first tag21 transition"); + assert!((at_first_transition[1].expect("node 1") - 1.8).abs() < 1.0e-5); + + let at_second_transition = + sample_control_node_times(&control, 1800, &gate_values, &mut state) + .expect("second tag21 transition"); + assert!((at_second_transition[1].expect("node 1") - 3.6).abs() < 1.0e-5); + + let at_wrap = sample_control_node_times(&control, 2700, &gate_values, &mut state) + .expect("wrapped tag21 transition"); + assert!((at_wrap[1].expect("node 1") - 1.4).abs() < 1.0e-5); + + let at_fullcycle = sample_control_node_times(&control, 18_000, &gate_values, &mut state) + .expect("fullcycle tag21 transition"); + assert!( + (at_fullcycle[1].expect("node 1") - 4.0).abs() < 1.0e-4, + "full-cycle node time was {at_fullcycle:?}" + ); + let zero_provider_value = 0.0_f32; + let gate_closed = [Some(zero_provider_value)]; + let mut closed_gate_state = ControlInstanceState::default(); + for clock in [0, 100, 500, 1000] { + let sample = + sample_control_node_times(&control, clock, &gate_closed, &mut closed_gate_state) + .expect("closed native gate holds the initial blend"); + assert_eq!(sample[1], Some(0.0)); + } + let reopened_provider_value = 1.0_f32; + let reopened_gate = [Some(reopened_provider_value)]; + let opened_gate = + sample_control_node_times(&control, 1001, &reopened_gate, &mut closed_gate_state) + .expect("open native gate starts a transition"); + assert!(opened_gate[1].expect("node 1") > 0.0); + } + + #[test] + fn tag21_sampler_preserves_fast_rates_and_rejects_previous_row_mapping() { + let mut fast = tag21_fullcycle_control(); + fast.timeline_rows[0].raw_parameters[2] = 20.0_f32.to_bits(); + let mut fast_state = ControlInstanceState::default(); + let at_half_transition = + sample_control_node_times(&fast, 11, &[Some(1.0)], &mut fast_state) + .expect("fast sample"); + assert!((at_half_transition[1].expect("node 1") - 0.88).abs() < 1.0e-5); + let at_transition_end = sample_control_node_times(&fast, 23, &[Some(1.0)], &mut fast_state) + .expect("fast transition"); + assert!((at_transition_end[1].expect("node 1") - 1.84).abs() < 1.0e-5); + + let mut direct = tag21_fullcycle_control(); + direct.timeline_rows[0].flags = 0; + direct.typed_records[0].fixed_words[9] = 2.0_f32.to_bits(); + let direct_sample = sample_control_node_times( + &direct, + 100, + &[Some(1.0)], + &mut ControlInstanceState::default(), + ) + .expect("non-wrapping transition"); + assert!((direct_sample[1].expect("node 1") - 0.4).abs() < 1.0e-5); + + let mut previous_row = tag21_fullcycle_control(); + previous_row.timeline_rows[0].flags = 0x40; + let error = sample_control_node_times( + &previous_row, + 0, + &[Some(1.0)], + &mut ControlInstanceState::default(), + ) + .expect_err("previous-row mapping must not silently use the wrong blend"); + assert_eq!( + error, + ControlSamplingError::UnsupportedPreviousRowMapping { row_index: 0 } + ); + } + #[test] fn component_control_rows_preserve_f32_values_and_source_order() { let source = ResourceKey { @@ -3466,7 +4308,7 @@ mod tests { payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); payload.extend_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); - let control = decode_component_control_payload(&payload, source, 2, 3) + let control = decode_component_control_payload(&payload, source.clone(), 2, 3) .expect("unknown tag is retained"); assert_eq!(control.bindings.len(), 1); @@ -3496,7 +4338,7 @@ mod tests { payload.resize(group_header_start + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); payload.extend_from_slice(&0_u32.to_le_bytes()); - let control = decode_component_control_payload(&payload, source, 2, 3) + let control = decode_component_control_payload(&payload, source.clone(), 2, 3) .expect("zero-length record has no trailing string byte"); assert_eq!(control.bindings.len(), 1); @@ -3507,6 +4349,39 @@ mod tests { } #[test] + fn component_control_tags2_and5_share_the_typed_cursor_parser() { + let source = ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(b"parts.ctl"), + type_id: None, + }; + let mut payload = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]); + payload[12..16].copy_from_slice(&3_u32.to_le_bytes()); + append_typed_record(&mut payload, 2, 0); + append_typed_record(&mut payload, 5, 2); + payload.extend_from_slice(b"fx\0"); + append_typed_record(&mut payload, 21, 0); + + let control = decode_component_control_payload(&payload, source, 2, 0) + .expect("tags 2 and 5 share the native typed cursor parser"); + + assert_eq!( + control + .typed_records + .iter() + .map(|record| record.tag) + .collect::<Vec<_>>(), + [2, 5, 21], + "tag 21 must begin at the cursor returned after tag 5's string" + ); + assert_eq!(control.typed_records[1].string_bytes, b"fx"); + assert_eq!( + control.effect_section, + PreparedControlEffectSection::Parsed(Vec::new()) + ); + } + + #[test] fn component_control_malformed_recognized_typed_tail_is_rejected() { let source = ResourceKey { archive: parse_path("controls.rlb").expect("archive"), @@ -3565,7 +4440,7 @@ mod tests { )], ); - let control = decode_component_control_payload(&payload, source, 2, 3) + let control = decode_component_control_payload(&payload, source.clone(), 2, 3) .expect("complete type-4 effect tables"); assert_eq!( @@ -3598,6 +4473,118 @@ mod tests { } #[test] + fn tag21_gate_provider_comes_from_validated_explicit_ndp() { + let source = ResourceKey { + archive: parse_path("parts.rlb").expect("CTL archive"), + name: resource_name(b"locator.ctl"), + type_id: None, + }; + let ndp_source = ResourceKey { + archive: parse_path("parts.rlb").expect("NDP archive"), + name: resource_name(b"locator.ndp"), + type_id: None, + }; + let control_payload = tag21_control_payload(0); + let ndp_payload = node_data_payload(1); + let repository = repository_with_archives(&[( + "parts.rlb", + &[ + (b"locator.ctl", &control_payload), + (b"locator.ndp", &ndp_payload), + ], + )]); + let prepared = prepare_component_control_from_resource( + &repository, + source.clone(), + None, + Some(ndp_source.clone()), + 2, + 0, + ) + .expect("prepare CTL and NDP") + .expect("CTL exists"); + let table = prepared.node_data.as_ref().expect("NDP table attached"); + assert_eq!(table.source, ndp_source); + assert_eq!(table.rows.len(), 1); + assert_eq!(table.rows[0].max_volume, 6000.0); + assert_eq!(table.rows[0].mobility_per_volume, 500.0); + assert!(matches!( + prepared.typed_records[0].gate_provider, + PreparedControlGateProvider::Resolved { + key: 0, + row_count: 1, + initial_type1_value: 1.0, + .. + } + )); + + let mut state = ControlInstanceState::default(); + sample_control_node_times(&prepared, 225, &[Some(1.0)], &mut state) + .expect("resolved nonzero provider opens the transition"); + + let missing_repository = + repository_with_archives(&[("parts.rlb", &[(b"locator.ctl", &control_payload)])]); + let missing = prepare_component_control_from_resource( + &missing_repository, + source.clone(), + None, + Some(ResourceKey { + archive: parse_path("parts.rlb").expect("archive"), + name: resource_name(b"missing.ndp"), + type_id: None, + }), + 2, + 0, + ) + .expect("missing optional NDP is retained as metadata") + .expect("CTL exists"); + assert!(matches!( + sample_control_node_times(&missing, 0, &[None], &mut ControlInstanceState::default()), + Err(ControlSamplingError::MissingGateProvider { key: 0, .. }) + )); + + let out_of_range_payload = tag21_control_payload(1); + let out_of_range_repository = repository_with_archives(&[( + "parts.rlb", + &[ + (b"locator.ctl", &out_of_range_payload), + (b"locator.ndp", &ndp_payload), + ], + )]); + let out_of_range = prepare_component_control_from_resource( + &out_of_range_repository, + source, + None, + Some(ResourceKey { + archive: parse_path("parts.rlb").expect("archive"), + name: resource_name(b"locator.ndp"), + type_id: None, + }), + 2, + 0, + ) + .expect("out-of-range key remains inspectable") + .expect("CTL exists"); + assert!(matches!( + sample_control_node_times( + &out_of_range, + 0, + &[None], + &mut ControlInstanceState::default(), + ), + Err(ControlSamplingError::GateProviderKeyOutOfRange { + key: 1, + row_count: 1, + .. + }) + )); + + let mut malformed_ndp = ndp_payload; + malformed_ndp.push(0); + assert!(decode_component_node_data_table(&malformed_ndp, ndp_source, 0).is_err()); + } + + #[test] fn component_control_loads_explicit_cpt_points_for_effect_offsets() { let mut control = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]); control[16..20].copy_from_slice(&1_u32.to_le_bytes()); @@ -3659,10 +4646,16 @@ mod tests { type_id: None, }; - let control = - prepare_component_control_from_resource(&repository, source, Some(point_source), 2, 0) - .expect("loaded CTL and its explicit CPT") - .expect("CTL entry"); + let control = prepare_component_control_from_resource( + &repository, + source, + Some(point_source), + None, + 2, + 0, + ) + .expect("loaded CTL and its explicit CPT") + .expect("CTL entry"); let point_list = control.point_list.as_ref().expect("point list is attached"); assert_eq!(point_list.source.archive.as_str(), "points.rlb"); @@ -3703,6 +4696,89 @@ mod tests { } #[test] + fn mission_assets_keep_explicit_bas_geometry_at_component_index() { + let mut bas = Vec::new(); + bas.extend_from_slice(&1_u32.to_le_bytes()); // first-section records + bas.extend_from_slice(&1_u32.to_le_bytes()); // declared count + for point in [[1.0_f32, 2.0, 3.0], [4.0, 5.0, 6.0]] { + for value in point { + bas.extend_from_slice(&value.to_le_bytes()); + } + } + bas.extend_from_slice(&0x1122_3344_u32.to_le_bytes()); + bas.extend_from_slice(&0x5566_7788_u32.to_le_bytes()); + bas.extend_from_slice(&1_u32.to_le_bytes()); // second-section records + bas.extend_from_slice(&1_u32.to_le_bytes()); + for point in [[7.0_f32, 8.0, 9.0], [10.0, 11.0, 12.0]] { + for value in point { + bas.extend_from_slice(&value.to_le_bytes()); + } + } + let repository = repository_with_archives(&[("fortif.rlb", &[(b"fr_b_plant.bas", &bas)])]); + let source = ResourceKey { + archive: parse_path("fortif.rlb").expect("archive"), + name: resource_name(b"fr_b_plant.bas"), + type_id: None, + }; + let without_bas = EffectivePrototype { + key: fparkan_prototype::PrototypeKey(resource_name(b"unrelated")), + geometry: PrototypeGeometry::NonGeometric, + source: fparkan_prototype::PrototypeSource::ObjectsRegistry, + source_type: None, + dependencies: Vec::new(), + control_resource: None, + point_resource: None, + node_data_resource: None, + bas_resource: None, + }; + let prototype = EffectivePrototype { + key: fparkan_prototype::PrototypeKey(resource_name(b"fr_b_plant")), + geometry: PrototypeGeometry::NonGeometric, + source: fparkan_prototype::PrototypeSource::ObjectsRegistry, + source_type: None, + dependencies: Vec::new(), + control_resource: None, + point_resource: None, + node_data_resource: None, + bas_resource: Some(source.clone()), + }; + + let assets = + prepare_mission_assets_with_repository(&repository, &[0..2], &[without_bas, prototype]) + .expect("mission BAS sidecar"); + assert_eq!(assets.object_component_bas[0].len(), 2); + assert!(assets.object_component_bas[0][0].is_none()); + let prepared = assets.object_component_bas[0][1] + .as_ref() + .expect("BAS aligned with component one"); + assert_eq!(prepared.source, source); + assert_eq!(prepared.geometry.first[0].points[1], [4.0, 5.0, 6.0]); + assert_eq!(prepared.geometry.first[0].opaque_words_a, [0x1122_3344]); + assert_eq!(prepared.geometry.second[0].points[1], [10.0, 11.0, 12.0]); + } + + #[test] + fn malformed_explicit_bas_geometry_is_reported_at_preparation_boundary() { + let bad_payload = u32::MAX.to_le_bytes(); + let repository = + repository_with_archives(&[("fortif.rlb", &[(b"broken.bas", &bad_payload)])]); + let source = ResourceKey { + archive: parse_path("fortif.rlb").expect("archive"), + name: resource_name(b"broken.bas"), + type_id: None, + }; + + let error = prepare_component_bas_from_resource(&repository, source, 3) + .expect_err("malformed BAS counts are rejected"); + assert!(matches!( + error, + AssetError::InvalidPrototype(message) + if message.contains("prototype 3 BAS geometry fortif.rlb:broken.bas") + && message.contains("limit") + )); + } + + #[test] fn component_control_preserves_unresolved_point_offset_sentinels() { let mut control = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]); control[16..20].copy_from_slice(&1_u32.to_le_bytes()); @@ -3734,10 +4810,16 @@ mod tests { type_id: None, }; - let control = - prepare_component_control_from_resource(&repository, source, Some(point_source), 2, 0) - .expect("point-offset sentinel is retained without guessed bounds") - .expect("CTL entry"); + let control = prepare_component_control_from_resource( + &repository, + source, + Some(point_source), + None, + 2, + 0, + ) + .expect("point-offset sentinel is retained without guessed bounds") + .expect("CTL entry"); let PreparedControlEffectSection::Parsed(rows) = control.effect_section else { panic!("effect rows parsed"); }; @@ -3784,6 +4866,7 @@ mod tests { &repository, source, Some(missing_point_source), + None, 2, 0, ) @@ -3837,6 +4920,46 @@ mod tests { payload } + fn tag21_control_payload(provider_key: u32) -> Vec<u8> { + let mut payload = component_control_payload(&[(1, 0.0, 4.0, 0.0, 1)]); + let row_start = 5 * 4 + 108; + payload[row_start + 0x10..row_start + 0x14].copy_from_slice(&u32::MAX.to_le_bytes()); + payload[row_start + 0x14..row_start + 0x18].copy_from_slice(&u32::MAX.to_le_bytes()); + payload[row_start + 0x18..row_start + 0x1C] + .copy_from_slice(&0.5_f32.to_bits().to_le_bytes()); + payload[row_start + 0x1C..row_start + 0x20] + .copy_from_slice(&AUTHORED_TAG21_RAW_PARAMETER_3.to_bits().to_le_bytes()); + payload[12..16].copy_from_slice(&1_u32.to_le_bytes()); + + let mut fixed = [0_u8; CONTROL_TYPED_RECORD_FIXED_BYTES]; + fixed[0..4].copy_from_slice(&21_u32.to_le_bytes()); + fixed[4..8].copy_from_slice(&provider_key.to_le_bytes()); + fixed[0x18..0x1C].copy_from_slice(&u32::MAX.to_le_bytes()); + fixed[0x24..0x28].copy_from_slice(&1.0_f32.to_bits().to_le_bytes()); + fixed[0x28..0x2C].copy_from_slice(&1.0_f32.to_bits().to_le_bytes()); + fixed[CONTROL_TYPED_RECORD_COUNT_OFFSET..CONTROL_TYPED_RECORD_COUNT_OFFSET + 4] + .copy_from_slice(&1_u32.to_le_bytes()); + payload.extend_from_slice(&fixed); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload + } + + fn node_data_payload(count: u32) -> Vec<u8> { + const ROW_BYTES: usize = 76; + let mut payload = vec![0; 4 + usize::try_from(count).expect("count") * ROW_BYTES]; + payload[0..4].copy_from_slice(&count.to_le_bytes()); + for row_index in 0..usize::try_from(count).expect("count") { + let start = 4 + row_index * ROW_BYTES; + payload[start..start + 4].copy_from_slice(&0_u32.to_le_bytes()); + payload[start + 4..start + 8].copy_from_slice(&6000.0_f32.to_bits().to_le_bytes()); + payload[start + 8..start + 12].copy_from_slice(&500.0_f32.to_bits().to_le_bytes()); + write_fixed_name(&mut payload[start + 12..start + 44], "system.rlb"); + write_fixed_name(&mut payload[start + 44..start + 76], "explode_frt_m.exp"); + } + payload + } + fn append_typed_record(payload: &mut Vec<u8>, tag: u32, string_length: u32) { let record_start = payload.len(); payload.resize(record_start + CONTROL_TYPED_RECORD_FIXED_BYTES + 4, 0); @@ -4044,6 +5167,8 @@ mod tests { dependencies: Vec::new(), control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }; let second = EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"mesh")), @@ -4058,6 +5183,8 @@ mod tests { dependencies: Vec::new(), control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }; assert_ne!(stable_visual_id(&first), stable_visual_id(&second)); @@ -4123,6 +5250,8 @@ mod tests { dependencies: vec![mesh_key.clone()], control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); let mut report = PrototypeGraphReport { @@ -4239,6 +5368,8 @@ mod tests { dependencies: Vec::new(), control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); graph @@ -4393,6 +5524,8 @@ mod tests { dependencies: Vec::new(), control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); let mut report = PrototypeGraphReport { @@ -4552,6 +5685,8 @@ mod tests { name: resource_name(b"attachments.cpt"), type_id: None, }), + node_data_resource: None, + bas_resource: None, }, EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"tree-b")), @@ -4565,6 +5700,8 @@ mod tests { type_id: None, }), point_resource: None, + node_data_resource: None, + bas_resource: None, }, EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"tree-c")), @@ -4578,6 +5715,8 @@ mod tests { type_id: None, }), point_resource: None, + node_data_resource: None, + bas_resource: None, }, ]; @@ -4672,6 +5811,8 @@ mod tests { type_id: None, }), point_resource: None, + node_data_resource: None, + bas_resource: None, }; let assets = prepare_mission_assets_with_repository(&repository, &[0..1], &[prototype]) @@ -4868,6 +6009,8 @@ mod tests { dependencies: vec![mesh_key.clone()], control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); let mut report = PrototypeGraphReport { @@ -5078,6 +6221,8 @@ mod tests { dependencies: Vec::new(), control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }, EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"tree_b")), @@ -5091,6 +6236,8 @@ mod tests { dependencies: Vec::new(), control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }, ]; (repo, prototypes) @@ -5253,6 +6400,7 @@ mod tests { prototype_requests: vec![prototype.key.clone()], root_prototype_request_spans: std::iter::once(0..1).collect(), root_unit_components: vec![Vec::new()], + root_unit_dat_header_flags: vec![None], visual_dependencies_expanded: false, nodes: vec![root_node, prototype_node, mesh_node], edges: vec![ diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs index a298f24..a1f6c13 100644 --- a/crates/fparkan-fx/src/environment.rs +++ b/crates/fparkan-fx/src/environment.rs @@ -1695,7 +1695,9 @@ impl PrecipitationEmitter { let size = camera.precipitation_size_scalar() * size_factor; for index in 0..desired { let particle = &mut self.particles[index]; - particle.previous_position = particle.position; + if dt_seconds > 0.0 { + particle.previous_position = particle.position; + } particle.position[0] += velocity[0] * dt_seconds; particle.position[1] += velocity[1] * dt_seconds; particle.position[2] += velocity[2] * dt_seconds; @@ -2470,6 +2472,29 @@ mod tests { } #[test] + fn duplicate_zero_delta_keeps_rain_frame_and_tail_unchanged() { + let mut system = EnvironmentSystem::new(3); + let camera = Camera::new([10.0, 20.0, 30.0]); + let weather = WeatherInput { + rain: WeatherState::with_resources(true, 0.01, [1.0; 4], ["atm_rain1.wav"]), + ..WeatherInput::clear() + }; + let _ = system.update(0.0, weather.clone(), camera); + let advanced = system.update(0.001, weather.clone(), camera); + let time_seconds = system.time_seconds(); + let repeated = system.update(0.0, weather, camera); + + assert_eq!(system.time_seconds(), time_seconds); + assert_eq!(advanced, repeated); + assert!(advanced.primitives.iter().any(|primitive| match primitive { + EnvironmentPrimitive::Particle { screen, .. } => { + screen.world_head != screen.world_tail + } + EnvironmentPrimitive::Lightning(_) => false, + })); + } + + #[test] fn precipitation_uses_native_volume_ratio_and_nearest_even_count() { let reference = PrecipitationVolume::native(); assert_eq!(reference.particle_count_for(reference, 1.0, 0.5004), 500); diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs index 9d52b7f..beb8526 100644 --- a/crates/fparkan-prototype/src/lib.rs +++ b/crates/fparkan-prototype/src/lib.rs @@ -2,7 +2,7 @@ //! Prototype registry and unit DAT primitives. use encoding_rs::WINDOWS_1251; -use fparkan_binary::{checked_count_bytes, Cursor, DecodeError}; +use fparkan_binary::{checked_allocation_len, checked_count_bytes, Cursor, DecodeError, Limits}; use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName}; use fparkan_resource::{ archive_path, resource_name, ResourceError, ResourceKey, ResourceRepository, @@ -18,6 +18,9 @@ const PROTOTYPE_INHERITANCE_DEPTH_LIMIT: usize = 32; /// `objects.rlb` external geometry entry type (`EXTO`). pub const PROTOTYPE_TYPE_EXTO: u32 = u32::from_le_bytes(*b"EXTO"); +/// Unit DAT second-header-word bit that enables native BAS placement handling. +pub const UNIT_DAT_BAS_PLACEMENT_FLAG: u32 = 0x8000_0000; + /// Prototype key. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct PrototypeKey(pub ResourceName); @@ -40,6 +43,48 @@ pub struct UnitDat { pub records: Vec<UnitComponentRecord>, } +/// BAS payload with two ordered point-record sections. +/// +/// The `first` and `second` fields describe stream order only. The native +/// loader reads one record shape in the first section and a point-only shape +/// in the second, but this type does not assign gameplay or geometry meaning +/// to either section. +#[derive(Clone, Debug, PartialEq)] +pub struct BasGeometry { + /// First ordered section, whose records also carry two opaque word arrays. + pub first: Vec<BasGeometryRecord>, + /// Second ordered section of point sequences. + pub second: Vec<BasPointSequence>, +} + +/// BAS record from the first ordered section. +#[derive(Clone, Debug, PartialEq)] +pub struct BasGeometryRecord { + /// Count value stored before the point sequence. + pub declared_count: u32, + /// `declared_count + 1` points copied by the native loader. + /// + /// Point sequences are preserved as read; closure is not imposed by the + /// binary decoder. + pub points: Vec<[f32; 3]>, + /// First opaque word array, containing `declared_count` entries. + pub opaque_words_a: Vec<u32>, + /// Second opaque word array, containing `declared_count` entries. + pub opaque_words_b: Vec<u32>, +} + +/// BAS point sequence from the second ordered section. +#[derive(Clone, Debug, PartialEq)] +pub struct BasPointSequence { + /// Count value stored before the point sequence. + pub declared_count: u32, + /// `declared_count + 1` points copied by the native loader. + /// + /// Point sequences are preserved as read; closure is not imposed by the + /// binary decoder. + pub points: Vec<[f32; 3]>, +} + /// Unit DAT binding used by mission object references. #[derive(Clone, Debug, Eq, PartialEq)] pub struct UnitDatBinding { @@ -114,6 +159,12 @@ pub struct EffectivePrototype { /// Explicit component point resource referenced by this prototype's /// resolved `objects.rlb` registry inheritance chain, when present. pub point_resource: Option<ResourceKey>, + /// Explicit component NDP/provider resource referenced by this + /// prototype's resolved `objects.rlb` registry inheritance chain. + pub node_data_resource: Option<ResourceKey>, + /// Explicit BAS geometry resource referenced by this prototype's resolved + /// `objects.rlb` registry inheritance chain. + pub bas_resource: Option<ResourceKey>, } /// Prototype resolution source. @@ -143,6 +194,10 @@ pub struct PrototypeGraph { /// assigning runtime semantics to their `kind`, links, descriptions, or /// opaque tails. pub root_unit_components: Vec<Vec<UnitComponentRecord>>, + /// Second word of each standard component Unit DAT header. This vector is + /// aligned with [`Self::roots`]; direct prototype roots, legacy one-model + /// bindings, and failed decodes are `None`. + pub root_unit_dat_header_flags: Vec<Option<u32>>, /// Whether visual dependency expansion has already been applied. pub visual_dependencies_expanded: bool, /// Materialized prototype dependency graph nodes. @@ -565,6 +620,190 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> { }) } +/// Decodes a BAS payload as two ordered sections of point records. +/// +/// Each section begins with a record count. Every record stores a declared +/// count followed by that count plus one little-endian `Vec3` values. Records +/// in the first section then carry two arrays of `declared_count` opaque +/// words. The decoder preserves points and words without assigning geometric +/// meaning or requiring the point sequence to be closed. +/// +/// # Errors +/// +/// Returns [`PrototypeError::Decode`] when a count exceeds the shared binary +/// limits, a record is truncated, a point is not finite, or trailing bytes +/// remain. +pub fn decode_bas_geometry(payload: &[u8]) -> Result<BasGeometry, PrototypeError> { + let limits = Limits::default(); + let payload_len = u64::try_from(payload.len()).map_err(|_| DecodeError::IntegerOverflow)?; + if payload_len > limits.max_file_bytes { + return Err(DecodeError::LimitExceeded { + count: payload_len, + limit: limits.max_file_bytes, + } + .into()); + } + + let mut cursor = Cursor::new(payload); + let mut budget = BasAllocationBudget::new(limits.max_decoded_bytes); + + let first_count = read_bas_count(&mut cursor, limits.max_entries)?; + check_bas_section_minimum(&cursor, first_count)?; + let first_capacity = + budget.reserve::<BasGeometryRecord>(u64::from(first_count), limits.max_entries)?; + let mut first = Vec::with_capacity(first_capacity); + for _ in 0..first_count { + let declared_count = read_bas_count(&mut cursor, limits.max_array_items)?; + let point_count = bas_point_count(declared_count, limits.max_array_items)?; + check_bas_first_record(&cursor, declared_count, point_count)?; + + let points = read_bas_points(&mut cursor, point_count, &mut budget, limits)?; + let opaque_words_a = read_bas_words(&mut cursor, declared_count, &mut budget, limits)?; + let opaque_words_b = read_bas_words(&mut cursor, declared_count, &mut budget, limits)?; + first.push(BasGeometryRecord { + declared_count, + points, + opaque_words_a, + opaque_words_b, + }); + } + + let second_count = read_bas_count(&mut cursor, limits.max_entries)?; + check_bas_section_minimum(&cursor, second_count)?; + let second_capacity = + budget.reserve::<BasPointSequence>(u64::from(second_count), limits.max_entries)?; + let mut second = Vec::with_capacity(second_capacity); + for _ in 0..second_count { + let declared_count = read_bas_count(&mut cursor, limits.max_array_items)?; + let point_count = bas_point_count(declared_count, limits.max_array_items)?; + let remaining = + u64::try_from(cursor.remaining()).map_err(|_| DecodeError::IntegerOverflow)?; + checked_count_bytes(point_count, 12, remaining)?; + let points = read_bas_points(&mut cursor, point_count, &mut budget, limits)?; + second.push(BasPointSequence { + declared_count, + points, + }); + } + + cursor.require_eof()?; + Ok(BasGeometry { first, second }) +} + +fn read_bas_count(cursor: &mut Cursor<'_>, limit: u32) -> Result<u32, DecodeError> { + let count = cursor.read_u32_le()?; + if count > limit { + return Err(DecodeError::LimitExceeded { + count: u64::from(count), + limit: u64::from(limit), + }); + } + Ok(count) +} + +fn bas_point_count(declared_count: u32, limit: u32) -> Result<u64, DecodeError> { + let point_count = u64::from(declared_count) + .checked_add(1) + .ok_or(DecodeError::IntegerOverflow)?; + if point_count > u64::from(limit) { + return Err(DecodeError::LimitExceeded { + count: point_count, + limit: u64::from(limit), + }); + } + Ok(point_count) +} + +fn check_bas_section_minimum(cursor: &Cursor<'_>, record_count: u32) -> Result<(), DecodeError> { + let remaining = u64::try_from(cursor.remaining()).map_err(|_| DecodeError::IntegerOverflow)?; + // Each record has a count word and at least one point (count zero). + checked_count_bytes(u64::from(record_count), 16, remaining)?; + Ok(()) +} + +fn check_bas_first_record( + cursor: &Cursor<'_>, + declared_count: u32, + point_count: u64, +) -> Result<(), DecodeError> { + let remaining = u64::try_from(cursor.remaining()).map_err(|_| DecodeError::IntegerOverflow)?; + let point_bytes = checked_count_bytes(point_count, 12, remaining)?; + let after_points = remaining + .checked_sub(u64::try_from(point_bytes).map_err(|_| DecodeError::IntegerOverflow)?) + .ok_or(DecodeError::IntegerOverflow)?; + // The two unnamed word arrays each contain `declared_count` u32 entries. + checked_count_bytes(u64::from(declared_count), 8, after_points)?; + Ok(()) +} + +fn read_bas_points( + cursor: &mut Cursor<'_>, + point_count: u64, + budget: &mut BasAllocationBudget, + limits: Limits, +) -> Result<Vec<[f32; 3]>, DecodeError> { + let capacity = budget.reserve::<[f32; 3]>(point_count, limits.max_array_items)?; + let mut points = Vec::with_capacity(capacity); + for _ in 0..point_count { + let point = [ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ]; + if !point.iter().all(|value| value.is_finite()) { + return Err(DecodeError::Invalid("BAS point is not finite")); + } + points.push(point); + } + Ok(points) +} + +fn read_bas_words( + cursor: &mut Cursor<'_>, + word_count: u32, + budget: &mut BasAllocationBudget, + limits: Limits, +) -> Result<Vec<u32>, DecodeError> { + let capacity = budget.reserve::<u32>(u64::from(word_count), limits.max_array_items)?; + let mut words = Vec::with_capacity(capacity); + for _ in 0..word_count { + words.push(cursor.read_u32_le()?); + } + Ok(words) +} + +struct BasAllocationBudget { + used: u64, + limit: u64, +} + +impl BasAllocationBudget { + const fn new(limit: u64) -> Self { + Self { used: 0, limit } + } + + fn reserve<T>(&mut self, count: u64, count_limit: u32) -> Result<usize, DecodeError> { + let capacity = checked_allocation_len(count, u64::from(count_limit))?; + let element_size = + u64::try_from(std::mem::size_of::<T>()).map_err(|_| DecodeError::IntegerOverflow)?; + let allocation = count + .checked_mul(element_size) + .ok_or(DecodeError::IntegerOverflow)?; + let total = self + .used + .checked_add(allocation) + .ok_or(DecodeError::IntegerOverflow)?; + if total > self.limit { + return Err(DecodeError::LimitExceeded { + count: total, + limit: self.limit, + }); + } + self.used = total; + Ok(capacity) + } +} + /// Builds the immediate-child tree encoded by Unit DAT record counts. /// /// Records are stored in preorder. The final word of each 112-byte record is @@ -747,6 +986,7 @@ struct ResolvedPrototypeRequests { expected_count: usize, prototypes: Vec<EffectivePrototype>, unit_components: Vec<UnitComponentRecord>, + unit_dat_header_flags: Option<u32>, } fn resolve_prototype_requests( @@ -763,6 +1003,7 @@ fn resolve_prototype_requests( expected_count: 1, prototypes: prototype.into_iter().collect(), unit_components: Vec::new(), + unit_dat_header_flags: None, }) } @@ -798,6 +1039,11 @@ fn resolve_unit_dat_prototype_requests( expected_count: unit.records.len(), prototypes, unit_components: unit.records, + unit_dat_header_flags: Some(u32::from_le_bytes( + unit.header_opaque[4..8] + .try_into() + .expect("fixed Unit DAT header slice"), + )), }); } @@ -810,6 +1056,7 @@ fn resolve_unit_dat_prototype_requests( expected_count: 1, prototypes: prototype.into_iter().collect(), unit_components: Vec::new(), + unit_dat_header_flags: None, }) } @@ -857,6 +1104,9 @@ pub fn build_prototype_graph( graph .root_unit_components .push(expansion.unit_components.clone()); + graph + .root_unit_dat_header_flags + .push(expansion.unit_dat_header_flags); let unit_component_count = expansion.unit_components.len(); for (prototype_index, prototype) in expansion.prototypes.into_iter().enumerate() { let unit_component_index = is_unit_dat_root @@ -963,6 +1213,9 @@ pub fn build_prototype_graph_report( graph .root_unit_components .push(expansion.unit_components.clone()); + graph + .root_unit_dat_header_flags + .push(expansion.unit_dat_header_flags); if edge == PrototypeGraphEdge::MissionToUnitDat { report.unit_component_count += expected; } @@ -1063,6 +1316,9 @@ pub fn build_prototype_graph_report( if graph.root_unit_components.len() <= root_index { graph.root_unit_components.push(Vec::new()); } + if graph.root_unit_dat_header_flags.len() <= root_index { + graph.root_unit_dat_header_flags.push(None); + } let end = graph.prototype_requests.len(); graph.root_prototype_request_spans.push(start..end); } @@ -1151,6 +1407,8 @@ fn resolve_archive_model( None, None, None, + None, + None, ))) } @@ -1176,6 +1434,8 @@ fn resolve_objects_registry_model( }; let control_resource = resolve_explicit_control_resource(object_key, &refs)?; let point_resource = resolve_explicit_point_resource(object_key, &refs)?; + let node_data_resource = resolve_explicit_node_data_resource(object_key, &refs)?; + let bas_resource = resolve_explicit_bas_resource(object_key, &refs)?; let mut missing_mesh_refs = Vec::new(); for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) { @@ -1187,6 +1447,8 @@ fn resolve_objects_registry_model( source_type, control_resource.clone(), point_resource.clone(), + node_data_resource.clone(), + bas_resource.clone(), )? { return Ok(Some(prototype)); } @@ -1208,6 +1470,8 @@ fn resolve_objects_registry_model( dependencies: Vec::new(), control_resource, point_resource, + node_data_resource, + bas_resource, })) } @@ -1276,6 +1540,8 @@ fn resolve_object_ref_model( source_type: Option<u32>, control_resource: Option<ResourceKey>, point_resource: Option<ResourceKey>, + node_data_resource: Option<ResourceKey>, + bas_resource: Option<ResourceKey>, ) -> Result<Option<EffectivePrototype>, PrototypeError> { let archive = normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?; let Some(mesh) = find_mesh_resource(repository, &archive, &ResourceName(model_name.to_vec()))? @@ -1289,6 +1555,8 @@ fn resolve_object_ref_model( source_type, control_resource, point_resource, + node_data_resource, + bas_resource, ))) } @@ -1304,6 +1572,14 @@ fn is_explicit_point_ref(item: &ObjectRefRecord) -> bool { has_extension_bytes(cstr_bytes(&item.resource_raw), b"cpt") } +fn is_explicit_node_data_ref(item: &ObjectRefRecord) -> bool { + has_extension_bytes(cstr_bytes(&item.resource_raw), b"ndp") +} + +fn is_explicit_bas_ref(item: &ObjectRefRecord) -> bool { + has_extension_bytes(cstr_bytes(&item.resource_raw), b"bas") +} + fn resolve_explicit_control_resource( requested: &ResourceName, refs: &[ObjectRefRecord], @@ -1318,6 +1594,20 @@ fn resolve_explicit_point_resource( resolve_explicit_resource(requested, refs, is_explicit_point_ref, "point") } +fn resolve_explicit_node_data_resource( + requested: &ResourceName, + refs: &[ObjectRefRecord], +) -> Result<Option<ResourceKey>, PrototypeError> { + resolve_explicit_resource(requested, refs, is_explicit_node_data_ref, "node data") +} + +fn resolve_explicit_bas_resource( + requested: &ResourceName, + refs: &[ObjectRefRecord], +) -> Result<Option<ResourceKey>, PrototypeError> { + resolve_explicit_resource(requested, refs, is_explicit_bas_ref, "BAS geometry") +} + fn resolve_explicit_resource( requested: &ResourceName, refs: &[ObjectRefRecord], @@ -1421,6 +1711,8 @@ fn effective( source_type: Option<u32>, control_resource: Option<ResourceKey>, point_resource: Option<ResourceKey>, + node_data_resource: Option<ResourceKey>, + bas_resource: Option<ResourceKey>, ) -> EffectivePrototype { EffectivePrototype { key: PrototypeKey(requested), @@ -1430,6 +1722,8 @@ fn effective( dependencies: vec![mesh], control_resource, point_resource, + node_data_resource, + bas_resource, } } @@ -1565,6 +1859,70 @@ mod tests { } #[test] + fn bas_geometry_preserves_both_sections_and_open_point_sequences() { + let bytes = build_bas_geometry_payload(); + + let geometry = decode_bas_geometry(&bytes).expect("BAS geometry"); + + assert_eq!(geometry.first.len(), 2); + assert_eq!(geometry.first[0].declared_count, 1); + assert_eq!( + geometry.first[0].points, + vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]] + ); + assert_eq!(geometry.first[0].opaque_words_a, vec![0x1122_3344]); + assert_eq!(geometry.first[0].opaque_words_b, vec![0x5566_7788]); + assert_eq!(geometry.first[1].declared_count, 2); + assert_eq!( + geometry.first[1].points, + vec![[10.0, 11.0, 12.0], [13.0, 14.0, 15.0], [16.0, 17.0, 18.0]] + ); + assert_eq!(geometry.first[1].opaque_words_a, vec![7, 8]); + assert_eq!(geometry.first[1].opaque_words_b, vec![9, 10]); + assert_eq!(geometry.second.len(), 1); + assert_eq!(geometry.second[0].declared_count, 1); + assert_eq!( + geometry.second[0].points, + vec![[20.0, 21.0, 22.0], [23.0, 24.0, 25.0]] + ); + } + + #[test] + fn bas_geometry_rejects_truncated_records_and_extreme_counts() { + let mut truncated = build_bas_geometry_payload(); + truncated.pop(); + assert!(matches!( + decode_bas_geometry(&truncated), + Err(PrototypeError::Decode(DecodeError::UnexpectedEof { .. })) + )); + + assert!(matches!( + decode_bas_geometry(&u32::MAX.to_le_bytes()), + Err(PrototypeError::Decode(DecodeError::LimitExceeded { .. })) + )); + } + + #[test] + fn bas_geometry_rejects_non_finite_points_and_trailing_bytes() { + let mut non_finite = build_bas_geometry_payload(); + // The first point follows the first section and first record counts. + non_finite[8..12].copy_from_slice(&f32::NAN.to_bits().to_le_bytes()); + assert!(matches!( + decode_bas_geometry(&non_finite), + Err(PrototypeError::Decode(DecodeError::Invalid( + "BAS point is not finite" + ))) + )); + + let mut trailing = build_bas_geometry_payload(); + trailing.push(0); + assert!(matches!( + decode_bas_geometry(&trailing), + Err(PrototypeError::Decode(DecodeError::TrailingBytes { .. })) + )); + } + + #[test] fn unit_zero_records_uses_exact_size() { let bytes = [0_u8; 8]; let unit = decode_unit_dat(&bytes).expect("unit"); @@ -1606,6 +1964,10 @@ mod tests { let unit = decode_unit_dat(&bytes).expect("unit"); let record = &unit.records[0]; assert_eq!(unit.header_opaque, [0xF1, 0xF0, 1, 2, 3, 4, 5, 6]); + assert_eq!( + u32::from_le_bytes(unit.header_opaque[4..8].try_into().unwrap()), + 0x0605_0403 + ); assert_eq!(record.parent_or_link, -7); assert_eq!(&record.description_raw[..description.len()], description); assert_eq!(record.tail0, 0x1122_3344); @@ -1784,6 +2146,7 @@ mod tests { let (graph, resolved, report) = build_prototype_graph_report(&repo, vfs.as_ref(), &roots); assert_eq!(graph.roots.len(), 2); + assert_eq!(graph.root_unit_dat_header_flags, vec![None, None]); assert_eq!(resolved.len(), 1); assert_eq!(report.root_count, 2); assert_eq!(report.direct_reference_count, 2); @@ -1816,6 +2179,13 @@ mod tests { ); let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); + let (graph, _, report) = build_prototype_graph_report( + &repo, + vfs.as_ref(), + &[resource_name(b"UNITS/AUTO/unit.dat")], + ); + assert!(report.is_success()); + assert_eq!(graph.root_unit_dat_header_flags, vec![None]); let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"UNITS/AUTO/unit.dat")) .expect("resolve") @@ -1901,16 +2271,12 @@ mod tests { let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); let static_path = resource_archive_path(b"static.rlb").expect("static path"); let mesh = minimal_msh_payload(); - vfs.insert( - dat_path, - Arc::from( - build_unit_dat(&[ - (b"objects.rlb".as_slice(), b"component_a".as_slice()), - (b"objects.rlb".as_slice(), b"component_b".as_slice()), - ]) - .into_boxed_slice(), - ), - ); + let mut unit_dat = build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"component_a".as_slice()), + (b"objects.rlb".as_slice(), b"component_b".as_slice()), + ]); + unit_dat[4..8].copy_from_slice(&UNIT_DAT_BAS_PLACEMENT_FLAG.to_le_bytes()); + vfs.insert(dat_path, Arc::from(unit_dat.into_boxed_slice())); vfs.insert( objects_path, Arc::from( @@ -2055,16 +2421,12 @@ mod tests { let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); let static_path = resource_archive_path(b"static.rlb").expect("static path"); let mesh = minimal_msh_payload(); - vfs.insert( - dat_path, - Arc::from( - build_unit_dat(&[ - (b"objects.rlb".as_slice(), b"component_a".as_slice()), - (b"objects.rlb".as_slice(), b"component_b".as_slice()), - ]) - .into_boxed_slice(), - ), - ); + let mut unit_dat = build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"component_a".as_slice()), + (b"objects.rlb".as_slice(), b"component_b".as_slice()), + ]); + unit_dat[4..8].copy_from_slice(&UNIT_DAT_BAS_PLACEMENT_FLAG.to_le_bytes()); + vfs.insert(dat_path, Arc::from(unit_dat.into_boxed_slice())); vfs.insert( objects_path, Arc::from( @@ -2111,6 +2473,10 @@ mod tests { assert_eq!(graph.root_unit_components.len(), 1); assert_eq!(graph.root_unit_components[0].len(), 2); assert_eq!( + graph.root_unit_dat_header_flags, + vec![Some(UNIT_DAT_BAS_PLACEMENT_FLAG)] + ); + assert_eq!( cstr_bytes(&graph.root_unit_components[0][0].resource_raw), b"component_a" ); @@ -2171,6 +2537,7 @@ mod tests { (b"static.rlb".as_slice(), b"parent_proto.msh".as_slice()), (b"fortif.rlb".as_slice(), b"parent_proto.cpt".as_slice()), (b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()), + (b"fortif.rlb".as_slice(), b"parent_proto.ndp".as_slice()), ]) .as_slice(), ), @@ -2181,6 +2548,7 @@ mod tests { (b"fortif.rlb".as_slice(), b"child_proto.bas".as_slice()), (b"fortif.rlb".as_slice(), b"parent_proto.cpt".as_slice()), (b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()), + (b"fortif.rlb".as_slice(), b"parent_proto.ndp".as_slice()), ]) .as_slice(), ), @@ -2201,6 +2569,7 @@ mod tests { (b"child_proto.bas".as_slice(), b"base"), (b"parent_proto.ctl".as_slice(), b"control"), (b"parent_proto.cpt".as_slice(), b"points"), + (b"parent_proto.ndp".as_slice(), b"node data"), ]) .into_boxed_slice(), ), @@ -2223,6 +2592,16 @@ mod tests { let points = resolved.point_resource.expect("inherited explicit CPT"); assert_eq!(points.archive.as_str(), "fortif.rlb"); assert!(points.name.0.eq_ignore_ascii_case(b"parent_proto.cpt")); + let node_data = resolved + .node_data_resource + .expect("inherited explicit NDP provider table"); + assert_eq!(node_data.archive.as_str(), "fortif.rlb"); + assert!(node_data.name.0.eq_ignore_ascii_case(b"parent_proto.ndp")); + let bas = resolved + .bas_resource + .expect("child-local explicit BAS geometry"); + assert_eq!(bas.archive.as_str(), "fortif.rlb"); + assert!(bas.name.0.eq_ignore_ascii_case(b"child_proto.bas")); } #[test] @@ -2334,6 +2713,60 @@ mod tests { } #[test] + fn objects_registry_rejects_distinct_explicit_node_data() { + let raw_name = |value: &[u8]| { + let mut raw = [0; 32]; + raw[..value.len()].copy_from_slice(value); + raw + }; + let refs = [ + ObjectRefRecord { + archive_raw: raw_name(b"first.rlb"), + resource_raw: raw_name(b"first.ndp"), + }, + ObjectRefRecord { + archive_raw: raw_name(b"second.rlb"), + resource_raw: raw_name(b"second.ndp"), + }, + ]; + + let error = resolve_explicit_node_data_resource(&resource_name(b"prototype"), &refs) + .expect_err("distinct explicit node data resources are ambiguous"); + assert!(matches!( + error, + PrototypeError::Resource(ResourceError::Format(message)) + if message.contains("ambiguous explicit node data resources") + )); + } + + #[test] + fn objects_registry_rejects_distinct_explicit_bas_resources() { + let raw_name = |value: &[u8]| { + let mut raw = [0; 32]; + raw[..value.len()].copy_from_slice(value); + raw + }; + let refs = [ + ObjectRefRecord { + archive_raw: raw_name(b"first.rlb"), + resource_raw: raw_name(b"first.bas"), + }, + ObjectRefRecord { + archive_raw: raw_name(b"second.rlb"), + resource_raw: raw_name(b"second.bas"), + }, + ]; + + let error = resolve_explicit_bas_resource(&resource_name(b"prototype"), &refs) + .expect_err("distinct explicit BAS resources are ambiguous"); + assert!(matches!( + error, + PrototypeError::Resource(ResourceError::Format(message)) + if message.contains("ambiguous explicit BAS geometry resources") + )); + } + + #[test] fn objects_registry_inheritance_resolves_multiple_levels() { let mut vfs = MemoryVfs::default(); let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); @@ -2726,6 +3159,27 @@ mod tests { assert_eq!(mesh.archive.as_str().to_ascii_lowercase(), archive); assert!(mesh.name.0.eq_ignore_ascii_case(model)); } + + let plant = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"fr_b_plant")) + .expect("resolve factory prototype") + .expect("factory prototype"); + let bas = plant.bas_resource.expect("explicit BAS registry reference"); + assert_eq!(bas.archive.as_str().to_ascii_lowercase(), "fortif.rlb"); + assert!(bas.name.0.eq_ignore_ascii_case(b"fr_b_plant.bas")); + let bas_archive = repo + .open_archive(&bas.archive) + .expect("open explicit BAS archive"); + let bas_entry = repo + .find(bas_archive, &bas.name) + .expect("find explicit BAS entry") + .expect("BAS entry exists"); + let bas_payload = repo.read(bas_entry).expect("read explicit BAS entry"); + let geometry = + decode_bas_geometry(bas_payload.as_slice()).expect("decode licensed BAS geometry"); + assert_eq!(geometry.first.len(), 1); + assert_eq!(geometry.second.len(), 1); + assert_eq!(geometry.first[0].points.len(), 21); + assert_eq!(geometry.second[0].points.len(), 15); } #[test] @@ -2945,6 +3399,53 @@ mod tests { out } + fn build_bas_geometry_payload() -> Vec<u8> { + let mut out = Vec::new(); + out.extend_from_slice(&2_u32.to_le_bytes()); + + push_bas_record_with_words( + &mut out, + 1, + &[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + &[0x1122_3344], + &[0x5566_7788], + ); + push_bas_record_with_words( + &mut out, + 2, + &[[10.0, 11.0, 12.0], [13.0, 14.0, 15.0], [16.0, 17.0, 18.0]], + &[7, 8], + &[9, 10], + ); + + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + push_bas_points(&mut out, &[[20.0, 21.0, 22.0], [23.0, 24.0, 25.0]]); + out + } + + fn push_bas_record_with_words( + out: &mut Vec<u8>, + declared_count: u32, + points: &[[f32; 3]], + words_a: &[u32], + words_b: &[u32], + ) { + out.extend_from_slice(&declared_count.to_le_bytes()); + push_bas_points(out, points); + for word in words_a.iter().chain(words_b) { + out.extend_from_slice(&word.to_le_bytes()); + } + } + + fn push_bas_points(out: &mut Vec<u8>, points: &[[f32; 3]]) { + for point in points { + for value in point { + out.extend_from_slice(&value.to_le_bytes()); + } + } + } + fn build_object_refs(items: &[(&[u8], &[u8])]) -> Vec<u8> { let mut out = Vec::with_capacity(items.len() * 64); for (archive, resource) in items { diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index e0088a3..57bee81 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -12,7 +12,7 @@ use fparkan_assets::{ use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; use fparkan_prototype::{ build_prototype_graph_report, PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport, - UnitComponentRecord, + UnitComponentRecord, UNIT_DAT_BAS_PLACEMENT_FLAG, }; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; use fparkan_script::{ @@ -74,6 +74,13 @@ pub struct MissionObjectDraft { pub resource_name_raw: Vec<u8>, /// Raw identity/clan word from the mission. pub identity_or_clan_raw: u32, + /// Raw word after the mission object's instance name. Native BAS + /// placement uses its zero/nonzero state only when the Unit DAT header + /// gate is set. + pub raw_after_name: u32, + /// Second word of the root Unit DAT header, when it was decoded. A direct + /// prototype root has no Unit DAT header. + pub unit_dat_header_flags: Option<u32>, /// Raw position vector. pub position: [f32; 3], /// Raw orientation vector. @@ -91,6 +98,49 @@ pub struct MissionObjectDraft { pub unit_components: Vec<UnitComponentRecord>, } +/// Native terrain adjustment selected for a mission object's BAS geometry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BasementPlacementMode { + /// Flatten the BAS inner boundary to mean outer terrain height and adjust + /// the object root transform by the native placement delta. + FlattenInnerAndAdjustRoot, + /// Keep authored BAS inner heights and leave the object root transform as + /// supplied by the mission. + PreserveAuthoredInnerHeight, +} + +impl MissionObjectDraft { + /// Returns the BAS mode selected by the native Unit DAT gate and mission + /// word, independent of resource path naming. + #[must_use] + pub fn basement_placement_mode(&self) -> Option<BasementPlacementMode> { + let header_flags = self.unit_dat_header_flags?; + if header_flags & UNIT_DAT_BAS_PLACEMENT_FLAG == 0 { + return None; + } + Some(if self.raw_after_name == 0 { + BasementPlacementMode::FlattenInnerAndAdjustRoot + } else { + BasementPlacementMode::PreserveAuthoredInnerHeight + }) + } + + /// Returns this mission root's position after applying the native BAS + /// transform adjustment once. Native code subtracts + /// `authored_inner_z - mean_outer_z` from matrix element 11. Component + /// local transforms should continue to be composed beneath this adjusted + /// root position without applying the delta again. + #[must_use] + pub fn root_position_with_basement_delta(&self, placement_delta: f32) -> [f32; 3] { + let mut position = self.position; + if self.basement_placement_mode() == Some(BasementPlacementMode::FlattenInnerAndAdjustRoot) + { + position[2] -= placement_delta; + } + position + } +} + /// A compiled script bundle selected by one mission clan. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionScriptBundle { @@ -731,6 +781,12 @@ fn load_mission_with_options( original_id: u32::try_from(index).ok().map(OriginalObjectId), resource_name_raw: object.resource_raw.clone(), identity_or_clan_raw: object.identity_or_clan_raw, + raw_after_name: object.raw_after_name, + unit_dat_header_flags: prototype_graph + .root_unit_dat_header_flags + .get(index) + .copied() + .flatten(), position: object.position, orientation_raw: object.orientation, scale: object.scale, @@ -1329,6 +1385,58 @@ mod tests { use fparkan_vfs::{DirectoryVfs, MemoryVfs, VfsEntry, VfsMetadata}; use std::path::{Path, PathBuf}; + fn basement_draft(header_flags: Option<u32>, raw_after_name: u32) -> MissionObjectDraft { + MissionObjectDraft { + original_id: Some(OriginalObjectId(7)), + resource_name_raw: b"BUILDS/example.dat".to_vec(), + identity_or_clan_raw: 0, + raw_after_name, + unit_dat_header_flags: header_flags, + position: [12.0, 34.0, 100.0], + orientation_raw: [0.0; 3], + scale: [1.0; 3], + visual_ids: Vec::new(), + properties: Vec::new(), + unit_components: Vec::new(), + } + } + + #[test] + fn native_basement_mode_uses_unit_header_gate_not_builds_path() { + assert_eq!( + basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 0).basement_placement_mode(), + Some(BasementPlacementMode::FlattenInnerAndAdjustRoot) + ); + assert_eq!( + basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 1).basement_placement_mode(), + Some(BasementPlacementMode::PreserveAuthoredInnerHeight) + ); + assert_eq!(basement_draft(Some(0), 0).basement_placement_mode(), None); + assert_eq!(basement_draft(None, 1).basement_placement_mode(), None); + } + + #[test] + fn native_basement_delta_adjusts_only_flattened_root_once() { + let flatten = basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 0); + let root = flatten.root_position_with_basement_delta(3.25); + assert_eq!(root, [12.0, 34.0, 96.75]); + // A component remains local to the root; it receives the root shift + // through composition instead of applying the delta itself. + let child_local_z = 8.0; + assert_eq!(root[2] + child_local_z, 104.75); + + let preserve = basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 0x40); + assert_eq!( + preserve.root_position_with_basement_delta(3.25), + preserve.position + ); + let no_gate = basement_draft(Some(0), 0); + assert_eq!( + no_gate.root_position_with_basement_delta(3.25), + no_gate.position + ); + } + #[test] fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() { let mut engine = create(EngineServices::default()).expect("engine"); diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs index 88bd325..e4028f9 100644 --- a/crates/fparkan-terrain-format/src/lib.rs +++ b/crates/fparkan-terrain-format/src/lib.rs @@ -1853,12 +1853,44 @@ mod tests { missing_nodes.faces.clear(); assert_eq!(missing_nodes.face_lod_assignments(), Ok(Vec::new())); - let mut overlapping = mesh; + let mut overlapping = mesh.clone(); overlapping.slots.slots_raw[3][..2].copy_from_slice(&2_u16.to_le_bytes()); assert_eq!( overlapping.face_lod_assignments(), Err(TerrainLodError::DuplicateFaceAssignment { face: 2 }) ); + + let mut uncovered = mesh.clone(); + uncovered.slots.slots_raw[1][2..4].copy_from_slice(&0_u16.to_le_bytes()); + assert_eq!( + uncovered.face_lod_assignments(), + Err(TerrainLodError::MissingFaceAssignment { face: 1 }) + ); + + let mut out_of_bounds = mesh.clone(); + out_of_bounds.slots.slots_raw[3][..2].copy_from_slice(&4_u16.to_le_bytes()); + assert_eq!( + out_of_bounds.face_lod_assignments(), + Err(TerrainLodError::FaceRangeOutOfBounds { + node: 1, + lod: TerrainLod::Lod1, + slot: 3, + start: 4, + count: 1, + face_count: 4, + }) + ); + + let mut unsupported = mesh; + unsupported.nodes_raw[12..14].copy_from_slice(&0_u16.to_le_bytes()); + assert_eq!( + unsupported.face_lod_assignments(), + Err(TerrainLodError::UnsupportedNodeSlot { + node: 0, + slot_index: 2, + slot: 0, + }) + ); } #[test] diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index db55506..54946e7 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -341,7 +341,7 @@ impl TerrainWorld { 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) { + if !valid_foundation_triangle(fragment.positions, fragment.source_face.is_none()) { return Err(TerrainError::InvalidFoundationFragment { fragment: fragment_index, }); @@ -1031,7 +1031,7 @@ fn build_surfaces(mesh: &LandMeshDocument) -> Result<Vec<RuntimeTriangle>, Terra Ok(triangles) } -fn valid_foundation_triangle(vertices: [[f32; 3]; 3]) -> bool { +fn valid_foundation_triangle(vertices: [[f32; 3]; 3], allow_zero_xy_area: bool) -> bool { if !vertices.iter().flatten().all(|value| value.is_finite()) { return false; } @@ -1043,7 +1043,9 @@ fn valid_foundation_triangle(vertices: [[f32; 3]; 3]) -> bool { 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 + let cross = first[0] * second[1] - first[1] * second[0]; + // Native vertex welding can collapse generated band faces; query math naturally misses zero area. + cross != 0.0 || allow_zero_xy_area } fn surface_vertex( @@ -1270,6 +1272,49 @@ mod tests { } #[test] + fn surface_queries_use_lod0_and_reject_missing_node_metadata() { + let mut mesh = synthetic_land_mesh(); + mesh.positions = vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 100.0], + [1.0, 0.0, 100.0], + [0.0, 1.0, 100.0], + ]; + mesh.faces = vec![ + terrain_face(FullSurfaceMask(1), [0, 1, 2]), + terrain_face(FullSurfaceMask(2), [3, 4, 5]), + ]; + assign_terrain_lod_slots(&mut mesh, &[[0, 1]], &[[0, 1], [1, 1]]); + + let world = TerrainWorld::from_land_msh(&mesh).expect("LOD0 terrain"); + assert_eq!(world.surface_count(), 1); + assert_eq!(world.height_at([0.25, 0.25]).expect("height"), Some(0.0)); + assert_eq!( + world + .raycast([0.25, 0.25, 101.0], [0.0, 0.0, -1.0], FullSurfaceMask(2),) + .expect("LOD1-only mask"), + None + ); + + mesh.nodes_raw.clear(); + assert!(matches!( + TerrainWorld::from_land_msh(&mesh), + Err(TerrainError::InvalidLodMapping( + fparkan_terrain_format::TerrainLodError::MissingNodes { face_count: 2 } + )) + )); + mesh.faces.clear(); + assert_eq!( + TerrainWorld::from_land_msh(&mesh) + .expect("empty scene without Node38") + .surface_count(), + 0 + ); + } + + #[test] fn foundation_world_queries_use_the_canonical_cut_and_generated_band() { use crate::{build_basement_foundations, BasementFootprint}; @@ -1365,7 +1410,55 @@ mod tests { } #[test] - fn foundation_install_rejects_invalid_source_identity_masks_and_generated_flags() { + fn foundation_install_keeps_finite_welded_zero_area_band_for_queries() { + let mesh = synthetic_land_mesh(); + let positions = [ + [1644.398, 1118.5295, 132.54382], + [1644.4183, 1118.5342, 132.54755], + [1644.4183, 1118.5342, 132.54755], + ]; + let foundation = BasementFoundation { + fragments: vec![BasementFoundationFragment { + source_face: None, + surface_mask: FullSurfaceMask(0x8300), + positions, + }], + placements: Vec::new(), + }; + let world = TerrainWorld::from_land_msh(&mesh) + .expect("source world") + .with_basement_foundation(foundation) + .expect("finite generated fragment retained after native vertex welding"); + + assert_eq!(world.surface_count(), 1); + assert_eq!( + world + .basement_foundation() + .expect("installed foundation") + .fragments[0] + .positions, + positions + ); + assert_eq!( + world + .height_at([positions[1][0], positions[1][1]]) + .expect("degenerate height query"), + None + ); + assert_eq!( + world + .raycast( + [positions[1][0], positions[1][1], 200.0], + [0.0, 0.0, -1.0], + FullSurfaceMask(0), + ) + .expect("degenerate ray query"), + None + ); + } + + #[test] + fn foundation_install_rejects_invalid_source_identity_masks_flags_and_non_finite_geometry() { 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]]; @@ -1385,6 +1478,11 @@ mod tests { surface_mask: FullSurfaceMask(0x0300), positions: generated_triangle, }, + BasementFoundationFragment { + source_face: None, + surface_mask: FullSurfaceMask(0x8300), + positions: [[0.0, 0.0, 0.0], [0.25, 0.0, 0.0], [0.0, f32::NAN, 0.0]], + }, ]; for (index, fragment) in invalid.into_iter().enumerate() { @@ -1545,8 +1643,35 @@ mod tests { .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); let mesh = fparkan_terrain_format::decode_land_msh(&nres) .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + let expected_lod0_faces = mesh + .face_lod_assignments() + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")) + .iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod0) + .count(); let world = TerrainWorld::from_land_msh(&mesh) .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + assert_eq!( + world.surface_count(), + expected_lod0_faces, + "{corpus} {path:?}" + ); + if corpus == "IS" { + let map_name = path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .map(str::to_ascii_lowercase); + let expected = match map_name.as_deref() { + Some("automap") => Some(1_912), + Some("prol") => Some(5_868), + Some("tut_1") => Some(5_046), + _ => None, + }; + if let Some(expected) = expected { + assert_eq!(world.surface_count(), expected, "{corpus} {path:?}"); + } + } if world.surface_count() > SURFACE_BVH_LEAF_SIZE { let triangle = &world.surfaces[0]; let point = [ @@ -1602,15 +1727,31 @@ mod tests { aux18: Vec::new(), faces: vec![face0, face1], }; - let mut node = vec![u8::MAX; 38]; - node[8..10].copy_from_slice(&0_u16.to_le_bytes()); - let mut slot = [0_u8; 68]; - slot[2..4].copy_from_slice(&(mesh.faces.len() as u16).to_le_bytes()); - mesh.nodes_raw = node; - mesh.slots.slots_raw.push(slot); + assign_terrain_lod_slots(&mut mesh, &[[0, u16::MAX]], &[[0, 2]]); mesh } + fn assign_terrain_lod_slots( + mesh: &mut LandMeshDocument, + node_slots: &[[u16; 2]], + face_ranges: &[[u16; 2]], + ) { + const NODE_STRIDE: usize = 38; + let mut nodes = vec![u8::MAX; node_slots.len() * NODE_STRIDE]; + for (node_index, [lod0, lod1]) in node_slots.iter().copied().enumerate() { + let offset = node_index * NODE_STRIDE + 8; + nodes[offset..offset + 2].copy_from_slice(&lod0.to_le_bytes()); + nodes[offset + 2..offset + 4].copy_from_slice(&lod1.to_le_bytes()); + } + let mut slots = vec![[0_u8; 68]; face_ranges.len()]; + for (slot, [start, count]) in slots.iter_mut().zip(face_ranges.iter().copied()) { + slot[..2].copy_from_slice(&start.to_le_bytes()); + slot[2..4].copy_from_slice(&count.to_le_bytes()); + } + mesh.nodes_raw = nodes; + mesh.slots.slots_raw = slots; + } + fn terrain_face( flags: FullSurfaceMask, vertices: [u16; 3], diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md index 66ed052..0435a6a 100644 --- a/docs/reference/environment-effects.md +++ b/docs/reference/environment-effects.md @@ -104,6 +104,10 @@ N = round_even( координаты, и к каждой оси применяется floor-based wrap; 5. после wrap точка снова переводится в мир, а её хвост сбрасывается на голову. +Повторное обновление на том же времени имеет нулевой `dt`: позиция капли и +сохранённое начало хвоста остаются прежними. Хвост продвигается только при +положительном шаге; wrap по-прежнему сбрасывает его на голову. + Скорости не принадлежат системе координат камеры: ```text diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index e663389..ebecddd 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -424,6 +424,15 @@ type 4, в том числе четыре `f_smalllight_y` с offsets 104–107 offsets 119–121. В `fr_l_gener.ctl` найдено 35 строк type 4, включая `f_gener_ball` с offset 85. +Control.dll `+0x8B10` передаёт указатель текущей записи диспетчеру `+0x2D4B0`. +Теги 2 и 5 направляются в общий читатель `+0x21D50`: таблица виртуальных методов +тега 2 (`+0x3CBD8`) вызывает его через обёртку `+0x29650`, а таблица тега 5 +(`+0x3CDF8`) указывает прямо на него в позиции `+0x1C`. Читатель потребляет +заголовок размером `0xB0` байт со счётчиком `u32` по `+0xAC`, затем `count` +значений `u32`, слово длины и, при ненулевой длине, строку с завершающим NUL; +указатель возврата указывает на следующую запись. Поэтому для тегов 2 и 5 подтверждён общий +структурный формат; смысл их полей пока не назначен. + Resolver прототипов хранит явную registry-ссылку на `.cpt` отдельно от ссылки на `.ctl`. Если строки type 4 разобраны и явная ссылка на CPT есть, подготовка читает ровно указанные archive/name; имя CPT не выводится из имени CTL. В CPT |
