diff options
32 files changed, 22878 insertions, 2640 deletions
@@ -384,9 +384,9 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "encoding_rs" -version = "0.8.42" +version = "0.8.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" dependencies = [ "cfg-if", "core_detect", @@ -488,6 +488,14 @@ name = "fparkan-binary" version = "0.1.0" [[package]] +name = "fparkan-font" +version = "0.1.0" +dependencies = [ + "fparkan-nres", + "fparkan-texm", +] + +[[package]] name = "fparkan-fx" version = "0.1.0" dependencies = [ @@ -629,7 +637,9 @@ dependencies = [ name = "fparkan-runtime" version = "0.1.0" dependencies = [ + "fparkan-animation", "fparkan-assets", + "fparkan-fx", "fparkan-msh", "fparkan-path", "fparkan-platform", @@ -4,6 +4,7 @@ members = [ "crates/fparkan-animation", "crates/fparkan-assets", "crates/fparkan-binary", + "crates/fparkan-font", "crates/fparkan-fx", "crates/fparkan-material", "crates/fparkan-mission-format", @@ -5,8 +5,10 @@ интерактивную сцену выбранной карты: terrain, все доступные MSH-компоненты объектов, материалы, динамические тени, небо, погоду, lightning FX и звуковые события. Это просмотр карты с полной графической сценой; прохождение миссии и -игровое поведение юнитов пока не запускаются, а модели показываются в статической -позе, заданной сохранёнными настройками узлов прототипа. +игровое поведение юнитов пока не запускаются. Модели начинают с настроек узлов +прототипа, а поддерживаемые Control-анимации и эффекты обновляются по общим +часам сцены. Неподдерживаемые Control-треки остаются на сохранённой начальной +позе. ## Запуск @@ -28,6 +30,14 @@ cargo run --release -p fparkan-game -- ` потере фокуса обзор отключается и нажатые клавиши сбрасываются. `Esc` отпускает обзор или закрывает окно. +В интерактивном режиме каждое отрисовываемое строение (корневой объект класса +`FORT`) и его отрисовываемые компоненты участвуют в проверке движения с их +позами и преобразованиями присоединения. Камера проверяет непрерывный путь до +новой позиции после ограничения рельефом; при пересечении она остаётся на +прежней позиции, сохраняя новый поворот. Повторная отправка ожидающего кадра не +применяет шаг второй раз. Режим `--legacy-camera-capture` не использует +свободную камеру и сохраняет положение из захвата без изменений. + В каталоге миссии ищется `sky.ske`. Если файл найден, приложение читает её расписание и `sky.wea`; связанные MAT0/WEAR-материалы и `effects.rlb/env_lightning` загружаются из ресурсов игры. Без `sky.ske` @@ -38,23 +48,21 @@ cargo run --release -p fparkan-game -- ` луна, flares, молнии, дождь и снег, point lights, динамические тени и связанные звуковые события обновляются в каждом кадре. -`--frames N` ограничивает запуск числом представленных кадров для -smoke/readback-проверок; -значение `0` означает бесконечный интерактивный запуск. `--control-clock-ms N` -воспроизводит общий clock анимации MSH и материалов вместе с окружением от нуля -до `N` за `--frames` кадров и требует не менее двух кадров. Пересоздание -swapchain повторяет текущий sample времени и состояние мира; перед повторной -отправкой обновляются camera projection и viewport-зависимая геометрия. Это не -продвигает сцену или replay clock. Счётчик и replay clock переходят дальше -после представленного кадра. -`--legacy-camera-capture` читает сохранённый захват камеры и запускает -воспроизводимый режим без -free-flight управления. `--static-animation-frame <u16>` задаёт один кадр для -всех узлов. Если параметр не указан, приложение берёт сохранённые настройки -узлов из явной ссылки прототипа на `.ctl`; для узлов без такой настройки -используется кадр `0`. Статическая поза не запускает игровой Control, AI или -полное поведение юнитов. -`--static-material-phase <u16>` выбирает фиксированную фазу материала. +`--frames N` ограничивает запуск числом кадров для smoke/readback-проверок; +значение `0` означает бесконечный интерактивный запуск. `--legacy-camera-capture` +читает сохранённый захват камеры и запускает воспроизводимый режим без +free-flight управления. В обычном запуске накопленный runtime clock обновляет +MSH Control-анимацию, поддержанные Control FX и анимацию материалов. +`--control-clock-ms <u32>` вместе с `--frames N` (не меньше двух) воспроизводит +этот clock от нуля до заданного миллисекундного значения за выбранные кадры. + +`--static-animation-frame <u16>` задаёт один кадр для всех MSH-узлов и +выключает выбор MSH-позы из Control clock. Без override приложение берёт +начальные настройки узлов из явных `.ctl` bindings, затем применяет +поддержанные Control-треки. Узлы без настройки остаются на кадре `0`. Это не +запускает AI, миссионные события или полное поведение юнитов. +`--static-material-phase <u16>` фиксирует фазу MSH и terrain материалов; фаза +материала Control FX остаётся частью его normalized command timeline. `--readback-out target/frame.raw` сохраняет последний кадр GPU. `--validation` включает Vulkan validation layers и завершает запуск с ошибкой при предупреждении или ошибке. Readback сохраняет байты в формате surface без преобразования; diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index 3249012..f691b34 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -1379,8 +1379,10 @@ mod tests { normals: None, uv0: None, uv1: None, + type16_present: false, indices, batches, + triangle_descriptors: None, node_names: None, animation: None, } @@ -1438,6 +1440,7 @@ mod tests { normals: None, uv0: None, uv1: None, + type16_present: false, indices: Vec::new(), batches: vec![ batch(0, 0, 0), @@ -1446,6 +1449,7 @@ mod tests { ..batch(0, 0, 0) }, ], + triangle_descriptors: None, node_names: None, animation: None, }; @@ -1684,8 +1688,10 @@ mod tests { normals: None, uv0: None, uv1: None, + type16_present: false, indices: vec![0, 1, 2, 0, 1, 2], batches: vec![batch(0, 3, 0), batch(3, 3, 0)], + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![ @@ -1743,8 +1749,10 @@ mod tests { normals: None, uv0: None, uv1: None, + type16_present: false, indices: Vec::new(), batches: Vec::new(), + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![ @@ -1849,8 +1857,10 @@ mod tests { normals: None, uv0: None, uv1: None, + type16_present: false, indices: vec![0, 1, 2, 0, 1, 2], batches: vec![batch(0, 3, 0), batch(3, 3, 0)], + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![AnimKey24 { @@ -1940,8 +1950,10 @@ mod tests { normals: None, uv0: None, uv1: None, + type16_present: false, indices: vec![0, 1, 2], batches: vec![batch(0, 3, 0)], + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![AnimKey24 { diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index a2481f7..0e1cebb 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -423,6 +423,7 @@ impl VulkanSmokeRenderer { draw_range_sort_keys: vec![None; create_info.mesh.draw_ranges.len()], draw_order_dirty: false, draw_range_world_queue: vec![None; create_info.mesh.draw_ranges.len()], + draw_range_diffuse_alpha_overrides: vec![None; create_info.mesh.draw_ranges.len()], world_transparent_background_ranges: Vec::new(), draw_range_base_pipeline_states: create_info .mesh @@ -901,6 +902,27 @@ impl VulkanSmokeRenderer { Ok(()) } + /// Overrides one draw's diffuse alpha without changing its shared material. + pub fn set_draw_range_diffuse_alpha_override( + &mut self, + range_index: usize, + diffuse_alpha: Option<f32>, + ) -> Result<(), VulkanSmokeRendererError> { + if diffuse_alpha.is_some_and(|alpha| !alpha.is_finite() || !(0.0..=1.0).contains(&alpha)) { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw-range diffuse alpha must be finite and 0..1", + }); + } + let alpha_override = self + .draw_range_diffuse_alpha_overrides + .get_mut(range_index) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw-range alpha index is out of bounds", + })?; + *alpha_override = diffuse_alpha; + Ok(()) + } + /// Sets the native per-range alpha reference in legacy 0..=255 units. /// /// Zero remains an explicit value; use the native-default constant when @@ -1808,6 +1830,7 @@ impl VulkanSmokeRenderer { 0, vk::IndexType::UINT32, ); + let mut viewport_depth = None; for &range_index in &self.draw_order { let range = self.draw_ranges.get(range_index).ok_or( VulkanSmokeRendererError::InvariantViolation { @@ -1873,11 +1896,16 @@ impl VulkanSmokeRenderer { context: "static material alpha", }, )?; - let material_alphas = [ - material_alpha[0].to_ne_bytes(), - material_alpha[1].to_ne_bytes(), - ] - .concat(); + let diffuse_alpha_override = self + .draw_range_diffuse_alpha_overrides + .get(range_index) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "draw-range diffuse-alpha override", + })?; + let material_alphas = + draw_material_alphas(*material_alpha, *diffuse_alpha_override) + .map(f32::to_ne_bytes) + .concat(); device.device().cmd_push_constants( command_buffer, resources.pipeline_layout, @@ -1929,6 +1957,17 @@ impl VulkanSmokeRenderer { } else { *material_mode }; + let viewport = super::swapchain_resources::draw_viewport( + swapchain.report.plan.extent, + material_mode, + ); + let next_viewport_depth = [viewport.min_depth, viewport.max_depth]; + if viewport_depth != Some(next_viewport_depth) { + device + .device() + .cmd_set_viewport(command_buffer, 0, &[viewport]); + viewport_depth = Some(next_viewport_depth); + } let material_mode = [material_mode.to_ne_bytes()]; device.device().cmd_push_constants( command_buffer, @@ -2356,6 +2395,16 @@ fn apply_world_transparency( } } +fn draw_material_alphas( + material_alphas: [f32; 2], + diffuse_alpha_override: Option<f32>, +) -> [f32; 2] { + [ + diffuse_alpha_override.unwrap_or(material_alphas[0]), + material_alphas[1], + ] +} + fn world_material_pipeline_states( original: LegacyPipelineState, category: u8, @@ -2523,12 +2572,14 @@ mod tests { use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh}; use super::{ apply_geometry_range_updates, apply_world_transparency, completed_readback_buffer_index, - mark_draw_order_dirty_on_activity_change, set_and_mark_dirty_if_changed, - sorted_world_draw_order, take_runtime_children_with_validation_snapshot, - take_runtime_owners_in_dependency_order, validate_geometry_upload, - world_material_pipeline_states, RollbackOnDrop, + draw_material_alphas, mark_draw_order_dirty_on_activity_change, + set_and_mark_dirty_if_changed, sorted_world_draw_order, + take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order, + validate_geometry_upload, world_material_pipeline_states, RollbackOnDrop, + }; + use fparkan_render::{ + LegacyBlendMode, LegacyCullMode, LegacyDepthBias, LegacyDepthMode, LegacyPipelineState, }; - use fparkan_render::{LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState}; use std::cell::RefCell; use std::rc::Rc; @@ -3096,12 +3147,74 @@ mod tests { } #[test] + fn control_fx_queue_follows_opaque_and_sky_without_reactivating_empty_ranges() { + const SCREEN_GRADIENT: usize = 0; + const CONTROL_FX: usize = 1; + const TERRAIN: usize = 2; + const BUILDING: usize = 3; + const SHADOW: usize = 4; + const NEBULA: usize = 5; + const DOME: usize = 6; + const CLOUDS: usize = 7; + const EMPTY_CONTROL_FX: usize = 8; + const SUN: usize = 9; + const WEATHER: usize = 10; + let mut ranges = (0..11) + .map(|index| VulkanStaticDrawRange { + first_index: index * 3, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: LegacyPipelineState::default(), + terrain_base_only: false, + alpha_test_reference: 0, + }) + .collect::<Vec<_>>(); + ranges[EMPTY_CONTROL_FX].index_count = 0; + let mut world_queue = vec![None; ranges.len()]; + world_queue[TERRAIN] = Some(false); + world_queue[BUILDING] = Some(false); + world_queue[CONTROL_FX] = Some(true); + world_queue[EMPTY_CONTROL_FX] = Some(true); + let mut sort_keys = vec![None; ranges.len()]; + sort_keys[CONTROL_FX] = Some(20.0); + sort_keys[EMPTY_CONTROL_FX] = Some(30.0); + + let order = sorted_world_draw_order( + ranges.len(), + &ranges, + &world_queue, + &sort_keys, + &[NEBULA, DOME, CLOUDS], + ); + assert_eq!( + order, + vec![ + SCREEN_GRADIENT, + TERRAIN, + BUILDING, + SHADOW, + NEBULA, + DOME, + CLOUDS, + CONTROL_FX, + EMPTY_CONTROL_FX, + SUN, + WEATHER, + ] + ); + assert_eq!(ranges[EMPTY_CONTROL_FX].index_count, 0); + } + + #[test] fn material_category_and_transparency_preserve_world_state_in_either_order() { let original = LegacyPipelineState { blend: LegacyBlendMode::Opaque, depth: LegacyDepthMode::TestWrite, cull: LegacyCullMode::FrontFace, alpha_test: false, + depth_bias: LegacyDepthBias::ProjectedDecal, }; let (base, category_first) = world_material_pipeline_states(original, 1, Some(true)).expect("category 1"); @@ -3113,14 +3226,17 @@ mod tests { assert!(base.alpha_test); assert_eq!(base.depth, LegacyDepthMode::TestWrite); assert_eq!(base.cull, LegacyCullMode::FrontFace); + assert_eq!(base.depth_bias, LegacyDepthBias::ProjectedDecal); assert_eq!(category_first.depth, LegacyDepthMode::TestReadOnly); assert_eq!(category_first.cull, LegacyCullMode::FrontFace); + assert_eq!(category_first.depth_bias, LegacyDepthBias::ProjectedDecal); let fixed_state = LegacyPipelineState { blend: LegacyBlendMode::Additive, depth: LegacyDepthMode::Disabled, cull: LegacyCullMode::Disabled, alpha_test: false, + depth_bias: LegacyDepthBias::Disabled, }; assert_eq!( world_material_pipeline_states(fixed_state, 1, None), @@ -3129,4 +3245,22 @@ mod tests { assert!(world_material_pipeline_states(original, 5, Some(false)).is_none()); assert!(world_material_pipeline_states(fixed_state, 5, None).is_none()); } + + #[test] + fn draw_alpha_override_is_per_range_and_none_restores_material_alpha() { + let shared_material = [0.25, 0.5]; + + assert_eq!(draw_material_alphas(shared_material, Some(0.0)), [0.0, 0.5]); + assert_eq!(draw_material_alphas(shared_material, None), shared_material); + + let updated_material = [0.75, 0.125]; + assert_eq!( + draw_material_alphas(updated_material, None), + updated_material + ); + assert_eq!( + draw_material_alphas(updated_material, Some(0.0)), + [0.0, 0.125] + ); + } } diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 51f8384..278243b 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -756,8 +756,8 @@ impl VulkanStaticMesh { material_index: 0, lightmap_index: u8::MAX, batch_flags: 0, - terrain_base_only: false, pipeline_state: LegacyPipelineState::default(), + terrain_base_only: false, alpha_test_reference: 0, }], } @@ -992,8 +992,8 @@ mod static_mesh_tests { material_index: 0, lightmap_index: u8::MAX, batch_flags: 0, - terrain_base_only: false, pipeline_state: LegacyPipelineState::default(), + terrain_base_only: false, alpha_test_reference: 0, }], }; @@ -1023,8 +1023,8 @@ mod static_mesh_tests { material_index: 7, lightmap_index: u8::MAX, batch_flags: 0, - terrain_base_only: false, pipeline_state: LegacyPipelineState::default(), + terrain_base_only: false, alpha_test_reference: 0, }, VulkanStaticDrawRange { @@ -1033,8 +1033,8 @@ mod static_mesh_tests { material_index: 2, lightmap_index: u8::MAX, batch_flags: 0, - terrain_base_only: false, pipeline_state: LegacyPipelineState::default(), + terrain_base_only: false, alpha_test_reference: 0, }, ]; @@ -1101,6 +1101,11 @@ mod static_mesh_tests { }, ]; + assert_eq!( + resolve_draw_texture_indices(&ranges, &materials), + Ok(vec![1, 0]) + ); + let mut screen_tl = materials[0].clone(); screen_tl.screen_tl = true; screen_tl.sky_far_depth = true; @@ -1109,11 +1114,9 @@ mod static_mesh_tests { far_sky.unlit = true; far_sky.sky_far_depth = true; assert_eq!(far_sky.encoded_material_mode(), 5.0); - - assert_eq!( - resolve_draw_texture_indices(&ranges, &materials), - Ok(vec![1, 0]) - ); + let mut nebula_with_stars = far_sky.clone(); + nebula_with_stars.sky_nebula_stars = true; + assert_eq!(nebula_with_stars.encoded_material_mode(), 6.0); let mut non_finite_base = materials.clone(); non_finite_base[0].specular_rgb[0] = f32::NAN; @@ -1149,6 +1152,7 @@ mod static_mesh_tests { fn alpha_cutoff_is_dynamic_and_disabled_outside_alpha_test_pipeline() { let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0]; let disabled = VulkanStaticDrawRange { + terrain_base_only: false, alpha_test_reference: 200, ..base }; @@ -1157,6 +1161,7 @@ mod static_mesh_tests { alpha_test: true, ..LegacyPipelineState::default() }, + terrain_base_only: false, alpha_test_reference: 128, ..base }; @@ -1167,6 +1172,7 @@ mod static_mesh_tests { alpha_test: true, ..LegacyPipelineState::default() }, + terrain_base_only: false, alpha_test_reference: 0, ..base }; @@ -1176,6 +1182,7 @@ mod static_mesh_tests { alpha_test: true, ..LegacyPipelineState::default() }, + terrain_base_only: false, alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, ..base }; @@ -1456,6 +1463,9 @@ pub struct VulkanSmokeRenderer { /// a fixed screen/sky/weather pass; `Some(false)` is world opaque and /// `Some(true)` is world transparent. pub(super) draw_range_world_queue: Vec<Option<bool>>, + /// Optional diffuse-alpha override for each draw range, independent of + /// the shared material record. + pub(super) draw_range_diffuse_alpha_overrides: Vec<Option<f32>>, /// Fixed sky background ranges placed between world opaque and transparent. pub(super) world_transparent_background_ranges: Vec<usize>, /// Pipeline state before transparent queue depth policy is applied. @@ -1469,8 +1479,8 @@ pub struct VulkanSmokeRenderer { pub(super) material_selectors: Vec<u16>, /// Whether the host-side specular records need a fence-safe GPU upload. pub(super) material_specular_dirty: bool, - /// Encoded material mode pushed at offset 124: 0 lit, 1 unlit, 2 native - /// sky nebula/stars texture composite, 3 native lightmap composite. + /// Encoded material mode pushed at offset 124: low bits select the + /// combiner, bit 4 selects sky far-depth, and bit 8 selects screen/TL. pub(super) material_unlit: Vec<f32>, pub(super) readback_enabled: bool, pub(super) camera: VulkanStaticCamera, diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs index 89ca43a..973e8b1 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -3,7 +3,8 @@ use ash::vk; use fparkan_platform::DepthStencilSupport; use fparkan_render::{ - LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState, PipelineKey, + LegacyBlendMode, LegacyCullMode, LegacyDepthBias, LegacyDepthMode, LegacyPipelineState, + PipelineKey, }; use std::collections::BTreeMap; @@ -922,8 +923,25 @@ fn create_texture_descriptor_bundle( Ok((layout, pool, descriptor_sets, sampler)) } -fn extent_component_to_f32(value: u32) -> f32 { - value as f32 +pub(super) fn draw_viewport(extent: (u32, u32), material_mode: f32) -> vk::Viewport { + // Ngi32 RVA 0x7170 maps world depth to 0.1..0.99; TL and sky depth bypass it. + let explicit_depth = material_mode.round() as u32 & 12 != 0; + vk::Viewport { + x: 0.0, + y: 0.0, + width: extent.0 as f32, + height: extent.1 as f32, + min_depth: if explicit_depth { 0.0 } else { 0.1 }, + max_depth: if explicit_depth { 1.0 } else { 0.99 }, + } +} + +fn depth_bias_parameters(depth_bias: LegacyDepthBias) -> (bool, f32, f32) { + match depth_bias { + LegacyDepthBias::Disabled => (false, 0.0, 0.0), + // shortcut: Vulkan constantFactor=-1.0/slopeFactor=0.0 is empirical; verify native raster state before claiming parity. + LegacyDepthBias::ProjectedDecal => (true, -1.0, 0.0), + } } fn create_graphics_pipeline_cache( @@ -1033,14 +1051,7 @@ fn create_graphics_pipeline( let input_assembly_state = vk::PipelineInputAssemblyStateCreateInfo::default() .topology(vk::PrimitiveTopology::TRIANGLE_LIST) .primitive_restart_enable(false); - let viewports = [vk::Viewport { - x: 0.0, - y: 0.0, - width: extent_component_to_f32(extent.0), - height: extent_component_to_f32(extent.1), - min_depth: 0.0, - max_depth: 1.0, - }]; + let viewports = [draw_viewport(extent, 0.0)]; let scissors = [vk::Rect2D { offset: vk::Offset2D { x: 0, y: 0 }, extent: vk::Extent2D { @@ -1051,6 +1062,8 @@ fn create_graphics_pipeline( let viewport_state = vk::PipelineViewportStateCreateInfo::default() .viewports(&viewports) .scissors(&scissors); + let (depth_bias_enable, depth_bias_constant_factor, depth_bias_slope_factor) = + depth_bias_parameters(state.depth_bias); let rasterization_state = vk::PipelineRasterizationStateCreateInfo::default() .depth_clamp_enable(false) .rasterizer_discard_enable(false) @@ -1062,7 +1075,10 @@ fn create_graphics_pipeline( LegacyCullMode::FrontFace => vk::CullModeFlags::FRONT, }) .front_face(vk::FrontFace::CLOCKWISE) - .depth_bias_enable(false); + .depth_bias_enable(depth_bias_enable) + .depth_bias_constant_factor(depth_bias_constant_factor) + .depth_bias_slope_factor(depth_bias_slope_factor) + .depth_bias_clamp(0.0); let multisample_state = vk::PipelineMultisampleStateCreateInfo::default() .sample_shading_enable(false) .rasterization_samples(vk::SampleCountFlags::TYPE_1); @@ -1105,6 +1121,9 @@ fn create_graphics_pipeline( let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default() .logic_op_enable(false) .attachments(&color_blend_attachments); + let dynamic_states = [vk::DynamicState::VIEWPORT]; + let dynamic_state = + vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states); let create_info = vk::GraphicsPipelineCreateInfo::default() .stages(&shader_stages) .vertex_input_state(&vertex_input_state) @@ -1114,6 +1133,7 @@ fn create_graphics_pipeline( .multisample_state(&multisample_state) .depth_stencil_state(&depth_stencil_state) .color_blend_state(&color_blend_state) + .dynamic_state(&dynamic_state) .layout(pipeline_layout) .render_pass(render_pass) .subpass(0); @@ -1328,3 +1348,52 @@ fn destroy_partial_swapchain_resources( } } } + +#[cfg(test)] +mod tests { + use super::{depth_bias_parameters, draw_viewport}; + use fparkan_render::LegacyDepthBias; + + #[test] + fn projected_decal_bias_parameters_use_empirical_vulkan_factors() { + assert_eq!( + depth_bias_parameters(LegacyDepthBias::Disabled), + (false, 0.0, 0.0) + ); + assert_eq!( + depth_bias_parameters(LegacyDepthBias::ProjectedDecal), + (true, -1.0, 0.0) + ); + } + + #[test] + fn world_viewport_depth_keeps_distant_geometry_in_front_of_screen_sun() { + let projection = fparkan_render::LegacyD3d7Projection { + viewport: [0, 0, 1280, 1024], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: 1.04, + } + .try_direct3d7_projection_row_major() + .expect("native projection"); + for mode in [0.0, 1.0, 2.0, 3.0, 16.0, 17.0] { + let viewport = draw_viewport((1280, 1024), mode); + assert_eq!([viewport.width, viewport.height], [1280.0, 1024.0]); + assert_eq!([viewport.min_depth, viewport.max_depth], [0.1, 0.99]); + for distance in [0.5, 100.0, 350.0, 600.0, 700.0] { + let clip_z = distance * projection[10] + projection[14]; + let clip_w = distance * projection[11]; + let depth = viewport.min_depth + + (viewport.max_depth - viewport.min_depth) * (clip_z / clip_w); + assert!( + depth < 0.999, + "mode {mode}, distance {distance}, depth {depth}" + ); + } + } + for mode in [4.0, 5.0, 6.0, 7.0, 9.0] { + let viewport = draw_viewport((640, 480), mode); + assert_eq!([viewport.min_depth, viewport.max_depth], [0.0, 1.0]); + } + } +} diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml index bf0f540..4cb0326 100644 --- a/apps/fparkan-game/Cargo.toml +++ b/apps/fparkan-game/Cargo.toml @@ -14,6 +14,7 @@ fparkan-resource = { path = "../../crates/fparkan-resource" } fparkan-render = { path = "../../crates/fparkan-render" } fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" } fparkan-msh = { path = "../../crates/fparkan-msh" } +fparkan-material = { path = "../../crates/fparkan-material" } fparkan-prototype = { path = "../../crates/fparkan-prototype" } fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } fparkan-runtime = { path = "../../crates/fparkan-runtime" } @@ -24,6 +25,3 @@ rodio = { version = "0.23.0", default-features = false, features = ["playback", serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" winit = { version = "0.30", default-features = false, features = ["rwh_06"] } - -[dev-dependencies] -fparkan-material = { path = "../../crates/fparkan-material" } diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 633b6fe..33ed782 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -4,19 +4,28 @@ use fparkan_animation::{AnimationTime, NodePoseBuffer}; use fparkan_assets::{ + load_material_archive_name_texture_rgba8_and_phase_with_document_from_root, load_material_name_texture_rgba8_and_phase_with_document_from_root, load_resource_entry_bytes_from_root, load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root, - page_uv_transform, sample_control_node_times, sample_material_phase, ControlInstanceState, - ControlSamplingError, Mat0Document, MaterialPhase, MaterialPhaseSample, - PreparedComponentControl, PreparedControlGateProvider, PreparedControlNodeBinding, - PreparedMaterialPhase, PreparedTexture, PreparedTextureUsage, PreparedVisual, RgbaImage, - StandaloneWearMaterialLoader, + page_uv_transform, sample_control_node_times, sample_material_normalized_phase, + sample_material_phase, ControlInstanceState, ControlSamplingError, Mat0Document, MaterialPhase, + MaterialPhaseSample, PreparedComponentControl, PreparedControlEffectRow, + PreparedControlEffectSection, PreparedControlGateProvider, PreparedControlNodeBinding, + PreparedControlPoint, PreparedControlPointList, PreparedMaterialPhase, PreparedModel, + PreparedTexture, PreparedTextureUsage, PreparedVisual, RgbaImage, StandaloneWearMaterialLoader, }; -use fparkan_fx::atmosphere::TypedAtmosphere; +use fparkan_fx::ambient_op3::{ + evaluate_op3_type0_command, evaluate_op3_type1_command, evaluate_op3_type2_command, +}; +use fparkan_fx::atmosphere::{AtmosphereFrame, TypedAtmosphere}; use fparkan_fx::environment::{ - decode_env_lightning_fxid, Camera as FxCamera, EnvironmentFrame, EnvironmentPrimitive, - EnvironmentSystem, PrecipitationKind, ScreenBillboard, + decode_env_lightning_fxid, evaluate_op1_point_light_with_basis, Camera as FxCamera, + EnvironmentFrame, EnvironmentPrimitive, EnvironmentSystem, PrecipitationKind, ScreenBillboard, +}; +use fparkan_fx::fx_visual::{evaluate_f_gener_ball, evaluate_f_gener_ball_mesh}; +use fparkan_fx::op7::{ + evaluate_op7_emitter, Op7EmitterState, Op7RenderContext, Op7VisibilityProfile, }; use fparkan_fx::shadow::{ ShadowCamera, ShadowCaster, ShadowFrame, ShadowLight, ShadowLodSettings, ShadowPageCache, @@ -26,16 +35,21 @@ use fparkan_fx::sky::{ sprite_half_size_pixels, SkyFrame, SkyMaterials, SkyMesh, SkyPassKind, SkySpriteFrame, SkySystem, SunVisibility, SKY_FLARE_ROWS, }; +use fparkan_fx::smoke::{evaluate_op8_emitter, Op8DetailThresholdRange, SmokeEmitterState}; +use fparkan_fx::{advance_native_fx_random_state, decode_fxid, FxCommand, FxDocument, FxOpcode}; +#[cfg(test)] +use fparkan_material::{MaterialAnimationBlock, MaterialKey}; use fparkan_msh::{ node38_fallback_hierarchy, node38_sampled_hierarchy, node38_sampled_hierarchy_at_times, - ModelAsset, + selected_slot, Group, Lod, ModelAsset, NodeId, SlotId, }; use fparkan_path::ResourceName; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; -use fparkan_prototype::{unit_component_tree, UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO}; +use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO}; use fparkan_render::{ - LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthMode, - LegacyIron3dEulerTransform, LegacyPipelineState, RawCameraTransform, + legacy_msh_portal_alpha, LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, + LegacyDepthBias, LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState, + RawCameraTransform, }; use fparkan_render_vulkan::{ node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, @@ -46,22 +60,27 @@ use fparkan_render_vulkan::{ project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount, project_msh_to_static_mesh_in_world_space_with_node_sampled_poses, project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount, - VulkanDynamicDrawRange, VulkanNodePose, VulkanPointLight, VulkanSmokeFrameOutcome, - VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticDrawRange, - VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanStaticTextureMip, - VulkanStaticVertex, + VulkanDynamicDrawRange, VulkanNodePose, VulkanPointLight, VulkanReadbackArtifact, + VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, + VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh, + VulkanStaticTexture, VulkanStaticTextureMip, VulkanStaticVertex, }; use fparkan_runtime::{ create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets, - loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, MissionMode, + loaded_mission_object_drafts, loaded_terrain, query_building_segment_world, + BasementPlacementMode, BuildingMeshNode, EngineServices, MissionAssets, MissionMode, MissionObjectDraft, MissionRequest, }; +#[cfg(test)] +use fparkan_terrain::BasementFoundationFragment; use fparkan_terrain::{ + build_basement_foundations, BasementFootprint, BasementFoundation, BasementPlacement, FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld, }; use fparkan_vfs::DirectoryVfs; use serde::Deserialize; use std::collections::{HashMap, HashSet}; +use std::io::Write; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -153,11 +172,12 @@ fn run(args: &[String]) -> Result<String, String> { environment, audio, args.frames, - args.control_clock_ms, &args.mission, loaded.object_count, args.readback_out.as_deref(), + args.readback_sequence_out.as_deref(), args.validation, + args.control_clock_ms, StartupTimings { mission_assets: mission_assets_duration, terrain_materials: terrain_materials_duration, @@ -332,6 +352,11 @@ impl FreeFlightCamera { if !self.ground_clearance.is_finite() || self.ground_clearance < 0.0 { return Err("free-flight ground clearance must be finite and nonnegative".to_string()); } + if let Some([min, max]) = terrain.surface_bounds() { + for axis in 0..2 { + self.position[axis] = self.position[axis].clamp(min[axis], max[axis]); + } + } let Some(ground_height) = terrain .height_at([self.position[0], self.position[1]]) .map_err(|error| format!("free-flight terrain height query: {error}"))? @@ -348,7 +373,6 @@ impl FreeFlightCamera { self.position[2] = self.position[2].max(minimum_z); Ok(()) } - fn forward(self) -> [f32; 3] { let (sin_yaw, cos_yaw) = self.yaw.sin_cos(); let (sin_pitch, cos_pitch) = self.pitch.sin_cos(); @@ -617,6 +641,10 @@ impl PreviewCameraFrame { const ENVIRONMENT_SPRITE_ROWS: [usize; 2] = [3, 4]; const ENVIRONMENT_FLARE_SLOTS: usize = 12; const ENVIRONMENT_PARTICLE_SLOTS: usize = 1_000; +// shortcut: Effect.dll derives this from projected sphere size and a render-context +// quality table. Category 0 supplies a highest-detail mesh until that exact +// world category is available; this does not reproduce native LOD selection. +const AMBIENT_EFFECT_NATIVE_CATEGORY: u8 = 0; #[derive(Clone, Copy, Debug)] struct EnvironmentGpuRange { @@ -626,6 +654,200 @@ struct EnvironmentGpuRange { material_index: usize, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AmbientEffectKind { + Op3Type0, + Op3Type1, + Op3Type2, + GenerBall, + SmokePool, + Op7ParticlePool, +} + +#[derive(Clone, Debug)] +struct AmbientEffectGpuSlot { + kind: AmbientEffectKind, + time_mode: u32, + header_flags: u32, + random_instance_key: ControlEffectInstanceKey, + random_seed: u32, + phase_jitter: f32, + random_shift: [f32; 3], + owner_object_index: usize, + owner_component_index: usize, + owner_node_index: usize, + owner_component_is_mounted: bool, + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + mount_pose: Option<VulkanNodePose>, + local_from_effect: [[f32; 4]; 4], + control_source: String, + control_table_index: usize, + control_row_index: usize, + control_point_indices: [u32; 3], + control_point_owners: [i32; 3], + command: FxCommand, + command_index: usize, + duration_seconds: f32, + world_from_effect: [[f32; 4]; 4], + range: EnvironmentGpuRange, + additional_particle_ranges: Vec<EnvironmentGpuRange>, + material_animation_index: usize, + additional_particle_material_animation_indices: Vec<usize>, + smoke_emitter_state: Option<SmokeEmitterState>, + op7_emitter_state: Option<Op7EmitterState>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct AmbientEffectMeshPrimitive { + vertex_start: usize, + vertex_count: usize, + index_start: usize, + index_count: usize, +} + +#[derive(Clone, Debug)] +struct ControlPointLightSlot { + time_mode: u32, + header_flags: u32, + random_instance_key: ControlEffectInstanceKey, + random_seed: u32, + phase_jitter: f32, + random_shift: [f32; 3], + duration_seconds: f32, + owner_object_index: usize, + owner_component_index: usize, + owner_node_index: usize, + owner_component_is_mounted: bool, + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + mount_pose: Option<VulkanNodePose>, + local_from_effect: [[f32; 4]; 4], + world_from_effect: [[f32; 4]; 4], + command_index: usize, + command: FxCommand, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct ControlEffectInstanceKey { + object_index: usize, + component_index: usize, + table_index: usize, + row_index: usize, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct ControlEffectRandomSample { + phase_jitter: f32, + accumulated_shift: [f32; 3], +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SceneSampleId { + Live(u64), + Replay(u64), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct FrameClockSample { + id: SceneSampleId, + time_ms: u32, + delta_seconds: f32, +} + +#[derive(Clone, Debug)] +struct NativeFxRandom16 { + state: u32, +} + +impl NativeFxRandom16 { + fn new(seed: u32) -> Self { + Self { + state: if seed == 0 { 0xA341_316C } else { seed }, + } + } + + fn next_u16(&mut self) -> u16 { + advance_native_fx_random_state(&mut self.state) + } + + fn symmetric_sample(&mut self, amplitude: f32) -> f32 { + (f32::from(self.next_u16()) / 65_536.0 - 0.5) * amplitude + } +} + +#[derive(Clone, Debug)] +struct ControlEffectRandomState { + header_flags: u32, + phase_jitter_amplitude: f32, + random_shift_amplitude: [f32; 3], + phase_and_z_rng: NativeFxRandom16, + xy_rng: NativeFxRandom16, + sample: ControlEffectRandomSample, + last_update_id: Option<SceneSampleId>, + last_update_clock_ms: Option<u32>, +} + +impl ControlEffectRandomState { + fn new(seed: u32, header_flags: u32, phase_jitter: f32, random_shift: [f32; 3]) -> Self { + Self { + header_flags, + phase_jitter_amplitude: phase_jitter, + random_shift_amplitude: random_shift, + phase_and_z_rng: NativeFxRandom16::new(seed ^ 0xA511_E9B3), + xy_rng: NativeFxRandom16::new(seed ^ 0x63D8_3595), + sample: ControlEffectRandomSample::default(), + last_update_id: None, + last_update_clock_ms: None, + } + } + + fn advance( + &mut self, + sample_id: SceneSampleId, + control_clock_ms: u32, + initial_phase: f32, + ) -> ControlEffectRandomSample { + // Native updates consume process-global RNG streams. The preview uses + // a deterministic per-Control-row stream; sibling slots share one + // sample and cadence, but native cross-row RNG ordering remains open. + if self.last_update_id == Some(sample_id) { + return self.sample; + } + if self.header_flags & 1 != 0 && initial_phase != -1.0 { + self.sample.phase_jitter = self + .phase_and_z_rng + .symmetric_sample(self.phase_jitter_amplitude); + } else { + self.sample.phase_jitter = 0.0; + } + if self.header_flags & 8 != 0 { + let shift_z = self + .phase_and_z_rng + .symmetric_sample(self.random_shift_amplitude[2]); + let shift_y = self.xy_rng.symmetric_sample(self.random_shift_amplitude[1]); + let shift_x = self.xy_rng.symmetric_sample(self.random_shift_amplitude[0]); + self.sample.accumulated_shift[0] += shift_x; + self.sample.accumulated_shift[1] += shift_y; + self.sample.accumulated_shift[2] += shift_z; + } + self.last_update_id = Some(sample_id); + self.last_update_clock_ms = Some(control_clock_ms); + self.sample + } +} + +#[derive(Clone, Debug)] +struct PreparedEffectMaterial { + document: Mat0Document, + phase_material_indices: Vec<usize>, + phase_uv_transforms: Vec<[f32; 4]>, + phase_native_flag_40: Vec<bool>, + phase_directional_rgb: Vec<[f32; 3]>, + phase_additive_rgb: Vec<[f32; 3]>, + initial_pipeline_category: u8, +} + #[derive(Clone, Debug)] struct EnvironmentMaterialAsset { texture: VulkanStaticTexture, @@ -715,6 +937,23 @@ impl MaterialPhaseBinding { }) } + fn sample_normalized( + &self, + material_index: usize, + normalized_phase: f32, + ) -> Result<MaterialPhaseSample, String> { + sample_material_normalized_phase( + &self.document, + self.animation_block_index, + normalized_phase, + ) + .map_err(|error| { + format!( + "sample MAT0 material {material_index} at normalized phase {normalized_phase}: {error}" + ) + }) + } + fn page_uv_transform(&self, phase_index: usize) -> [f32; 4] { self.phase_uv_transforms .get(phase_index) @@ -740,11 +979,14 @@ impl MaterialPhaseBinding { struct MaterialVariantSet { phase_material_indices: Vec<usize>, range_indices: Vec<usize>, + pipeline_category: u8, } #[derive(Clone, Debug)] struct MaterialAnimationBinding { initial_phase_index: usize, + normalized_phase_override: Option<f32>, + ambient_fx_material_alpha: bool, variants: Vec<MaterialVariantSet>, base: MaterialPhaseBinding, detail: Option<MaterialPhaseBinding>, @@ -753,10 +995,39 @@ struct MaterialAnimationBinding { stage_textures: Option<MaterialStageTextures>, } -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug)] struct ActiveMaterialState { material_index: usize, + phase_index: usize, diffuse_alpha: f32, + additive_rgb: [f32; 3], +} + +fn ambient_effect_material_alpha(is_ambient_effect: bool, sampled_opacity: f32) -> f32 { + if is_ambient_effect { + 1.0 + } else { + sampled_opacity + } +} + +fn sample_material_animation_base( + binding: &mut MaterialAnimationBinding, + clock_ms: u32, +) -> Result<MaterialPhaseSample, String> { + match binding.normalized_phase_override { + Some(normalized_phase) => binding + .base + .sample_normalized(binding.initial_phase_index, normalized_phase), + None => binding.base.sample(binding.initial_phase_index, clock_ms), + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct AmbientEffectGeometryUpdate { + range_index: usize, + index_count: u32, + command_rgb: [f32; 3], } fn selected_material_phase( @@ -772,14 +1043,14 @@ fn selected_material_phase( Ok((material_index, pipeline_category)) } -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug)] struct MaterialRangeSelection { range_index: usize, material_index: usize, pipeline_category: u8, } -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug)] struct MaterialPropertiesUpdate { material_index: usize, diffuse_alpha: f32, @@ -795,14 +1066,14 @@ struct MaterialPropertiesUpdate { uv_transforms: [[f32; 4]; 4], } -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug)] struct MaterialStagePhaseUpdate { binding_index: usize, material_index: usize, phases: [usize; 3], } -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default)] struct MaterialFrameUpdate { active_materials: HashMap<usize, ActiveMaterialState>, range_selections: Vec<MaterialRangeSelection>, @@ -817,7 +1088,11 @@ fn sample_material_animations( ) -> Result<MaterialFrameUpdate, String> { let mut update = MaterialFrameUpdate::default(); for (binding_index, binding) in bindings.iter_mut().enumerate() { - let base_sample = binding.base.sample(binding.initial_phase_index, clock_ms)?; + let base_sample = sample_material_animation_base(binding, clock_ms)?; + let material_alpha = ambient_effect_material_alpha( + binding.ambient_fx_material_alpha, + base_sample.coefficients.opacity, + ); let detail_sample = binding .detail .as_mut() @@ -834,7 +1109,7 @@ fn sample_material_animations( .map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms)) .transpose()?; let mut updated_materials = HashSet::new(); - for variant in &binding.variants { + for variant in &mut binding.variants { let (material_index, pipeline_category) = selected_material_phase(variant, base_sample.phase_index, |index| { materials @@ -846,7 +1121,9 @@ fn sample_material_animations( range_index, ActiveMaterialState { material_index, - diffuse_alpha: base_sample.coefficients.opacity, + phase_index: base_sample.phase_index, + diffuse_alpha: material_alpha, + additive_rgb: base_sample.coefficients.additive_rgb, }, ); update.range_selections.push(MaterialRangeSelection { @@ -855,6 +1132,7 @@ fn sample_material_animations( pipeline_category, }); } + variant.pipeline_category = pipeline_category; if !updated_materials.insert(material_index) { continue; } @@ -866,9 +1144,7 @@ fn sample_material_animations( let (base_directional, base_additive, overlay_directional, overlay_additive) = if material.lightmap_mode { // Terrain mode 3 samples the lightmap in the secondary - // stage and uses the authored directional RGB as its - // multiplicative lightmap coefficient. It has no normal - // lighting or overlay stage. + // stage and uses the authored directional RGB as additive. ([0.0; 3], base_coefficients.directional_rgb, None, None) } else { ( @@ -878,16 +1154,27 @@ fn sample_material_animations( overlay_coefficients.map(|coefficients| coefficients.additive_rgb), ) }; - if !material.lightmap_mode && binding.stage_textures.is_some() { - update.stage_phases.push(MaterialStagePhaseUpdate { - binding_index, - material_index, - phases: [ + + if !material.lightmap_mode { + if let Some(stage_textures) = binding.stage_textures.as_ref() { + let stage_phases = [ detail_sample.map_or(0, |sample| sample.phase_index), overlay_sample.map_or(0, |sample| sample.phase_index), overlay_detail_sample.map_or(0, |sample| sample.phase_index), - ], - }); + ]; + if stage_textures + .applied_material_phases + .get(&material_index) + .copied() + != Some(stage_phases) + { + update.stage_phases.push(MaterialStagePhaseUpdate { + binding_index, + material_index, + phases: stage_phases, + }); + } + } } let overlay_uv = overlay_sample .map(|sample| { @@ -937,7 +1224,7 @@ fn sample_material_animations( .checked_page_uv_transform(base_sample.phase_index)?; update.properties.push(MaterialPropertiesUpdate { material_index, - diffuse_alpha: base_coefficients.opacity.clamp(0.0, 1.0), + diffuse_alpha: material_alpha.clamp(0.0, 1.0), overlay_alpha: overlay_coefficients .map_or(0.0, |coefficients| coefficients.opacity.clamp(0.0, 1.0)), base_directional, @@ -1048,6 +1335,31 @@ fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) - diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0 } +fn native_msh_diffuse_alpha_override( + mesh: &VulkanStaticMesh, + range_index: usize, + type16_present: bool, + eye_world: [f32; 3], + horizontal_fov: f32, +) -> Result<Option<f32>, String> { + let range = mesh + .draw_ranges + .get(range_index) + .ok_or_else(|| "portal draw range is out of bounds".to_string())?; + if type16_present || range.batch_flags & 0x0008 == 0 { + return Ok(None); + } + let distance = if range.batch_flags & 0x0050 == 0x0040 { + 0.0 + } else { + // shortcut: use the first indexed vertex; replace with native CPU clipping selection when clipped-portal captures are available. + first_indexed_vertex_distance(mesh, range_index, eye_world)? + }; + legacy_msh_portal_alpha(range.batch_flags, distance, horizontal_fov) + .map(Some) + .ok_or_else(|| "portal distance or field of view is invalid".to_string()) +} + fn native_msh_handedness(transform: LegacyIron3dEulerTransform, scale: [f32; 3]) -> i8 { let Some(origin) = transform.try_transform_scaled_point([0.0; 3], scale) else { return 0; @@ -1358,6 +1670,15 @@ fn world_pipeline_state(blend: LegacyBlendMode, depth: LegacyDepthMode) -> Legac } } +fn projected_shadow_pipeline_state() -> LegacyPipelineState { + LegacyPipelineState { + blend: LegacyBlendMode::SourceAlpha, + depth: LegacyDepthMode::TestReadOnly, + depth_bias: LegacyDepthBias::ProjectedDecal, + ..LegacyPipelineState::default() + } +} + fn environment_vertex(position: [f32; 3], color: [f32; 3], uv: [f32; 2]) -> VulkanStaticVertex { environment_vertex_with_alpha(position, color, 1.0, uv) } @@ -1499,9 +1820,12 @@ fn append_environment_material( let document = asset.document.clone(); material_animations.push(MaterialAnimationBinding { initial_phase_index: 0, + normalized_phase_override: None, + ambient_fx_material_alpha: false, variants: vec![MaterialVariantSet { phase_material_indices, range_indices: Vec::new(), + pipeline_category: phases[0].pipeline_category, }], base: MaterialPhaseBinding { document, @@ -1520,6 +1844,132 @@ fn append_environment_material( Ok(first_material_index) } +fn prepare_effect_material( + root: &Path, + archive: &str, + name: &str, + materials: &mut Vec<VulkanStaticMaterial>, + cache: &mut HashMap<String, PreparedEffectMaterial>, +) -> Result<PreparedEffectMaterial, String> { + let key = format!("{archive}/{name}"); + if let Some(prepared) = cache.get(&key) { + return Ok(prepared.clone()); + } + let (first_mips, first_phase, document) = + load_material_archive_name_texture_rgba8_and_phase_with_document_from_root( + root, archive, name, 0, + ) + .map_err(|error| format!("resolve FX MAT0 {key}: {error}"))?; + if document.phases.is_empty() { + return Err(format!("FX MAT0 {key} has no phases")); + } + + let mut phase_material_indices = Vec::with_capacity(document.phases.len()); + let mut phase_uv_transforms = Vec::with_capacity(document.phases.len()); + let mut phase_native_flag_40 = Vec::with_capacity(document.phases.len()); + let mut phase_directional_rgb = Vec::with_capacity(document.phases.len()); + let mut phase_additive_rgb = Vec::with_capacity(document.phases.len()); + for phase_index in 0..document.phases.len() { + let (mips, phase) = if phase_index == 0 { + (first_mips.clone(), first_phase) + } else { + let phase_index_u16 = u16::try_from(phase_index) + .map_err(|_| format!("FX MAT0 {key} phase index exceeds u16"))?; + let (mips, phase, _document) = + load_material_archive_name_texture_rgba8_and_phase_with_document_from_root( + root, + archive, + name, + phase_index_u16, + ) + .map_err(|error| format!("resolve FX MAT0 {key} phase {phase_index}: {error}"))?; + (mips, phase) + }; + let texture = vulkan_texture_from_rgba_mips(mips, "FX material")?; + let index = materials.len(); + let material_index = u16::try_from(index).map_err(|_| { + "FX effects exceed the available 16-bit material selector space".to_string() + })?; + materials.push(VulkanStaticMaterial { + material_index, + pipeline_category: phase.pipeline_category, + texture, + directional_rgb: phase.directional_rgb, + additive_rgb: phase.additive_rgb, + uv_transform: phase.page_uv_transform, + detail_texture: Some(solid_environment_texture([128, 128, 128, 255])), + overlay_texture: None, + overlay_detail_texture: None, + // FX descriptor opacity is carried by each frame's vertex alpha. + // Keep MAT0 alpha at one so it does not multiply the opacity a + // second time. + diffuse_alpha: 1.0, + overlay_diffuse_alpha: None, + overlay_directional_rgb: None, + overlay_additive_rgb: None, + specular_rgb: phase.specular_rgb, + specular_power: phase.power, + overlay_specular_rgb: None, + overlay_specular_power: None, + detail_uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_uv_transform: None, + overlay_detail_uv_transform: None, + unlit: true, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, + screen_tl: false, + }); + phase_material_indices.push(index); + phase_uv_transforms.push(phase.page_uv_transform); + phase_native_flag_40.push(phase.native_flag_40); + phase_directional_rgb.push(phase.directional_rgb); + phase_additive_rgb.push(phase.additive_rgb); + } + let prepared = PreparedEffectMaterial { + initial_pipeline_category: materials[phase_material_indices[0]].pipeline_category, + document, + phase_material_indices, + phase_uv_transforms, + phase_native_flag_40, + phase_directional_rgb, + phase_additive_rgb, + }; + cache.insert(key, prepared.clone()); + Ok(prepared) +} + +fn append_effect_material_animation( + prepared: &PreparedEffectMaterial, + range_index: usize, + bindings: &mut Vec<MaterialAnimationBinding>, +) -> usize { + let binding_index = bindings.len(); + bindings.push(MaterialAnimationBinding { + initial_phase_index: 0, + normalized_phase_override: None, + ambient_fx_material_alpha: true, + variants: vec![MaterialVariantSet { + phase_material_indices: prepared.phase_material_indices.clone(), + range_indices: vec![range_index], + pipeline_category: prepared.initial_pipeline_category, + }], + base: MaterialPhaseBinding { + document: prepared.document.clone(), + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: u32::try_from(binding_index).unwrap_or(u32::MAX), + frozen_phase_index: None, + phase_uv_transforms: prepared.phase_uv_transforms.clone(), + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }); + binding_index +} + fn append_environment_gradient_material( materials: &mut Vec<VulkanStaticMaterial>, ) -> Result<usize, String> { @@ -1636,53 +2086,58 @@ fn terrain_shadow_receivers( ) -> Result<Vec<ShadowTriangle>, String> { let assignments = terrain .face_lod_assignments() - .map_err(|error| error.to_string())?; - let mut receivers = Vec::with_capacity( - assignments - .iter() - .filter(|assignment| assignment.lod == TerrainLod::Lod0) - .count(), - ); - for assignment in assignments { - if assignment.lod != TerrainLod::Lod0 { - continue; - } - let face = terrain.faces.get(assignment.face_index).ok_or_else(|| { - format!( - "terrain LOD0 references unknown face {}", - assignment.face_index - ) - })?; - let positions = [ - terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[0])?, - terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[1])?, - terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[2])?, - ]; - receivers.push(shadow_triangle(positions, face.flags.0)); - } - Ok(receivers) + .map_err(|error| format!("select terrain LOD0 shadow receivers: {error}"))?; + assignments + .into_iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod0) + .map(|assignment| { + let face = terrain.faces.get(assignment.face_index).ok_or_else(|| { + format!( + "terrain LOD assignment references missing face {}", + assignment.face_index + ) + })?; + let positions = source_terrain_face_positions(terrain, assignment.face_index)?; + if !positions.iter().flatten().all(|value| value.is_finite()) { + return Err("terrain shadow receiver contains a non-finite position".to_string()); + } + Ok(shadow_triangle(positions, face.flags.0)) + }) + .collect() } -fn terrain_shadow_vertex_position( +fn source_terrain_face_positions( terrain: &fparkan_terrain_format::LandMeshDocument, face_index: usize, - vertex_index: u16, -) -> Result<[f32; 3], String> { - let position = terrain - .positions - .get(usize::from(vertex_index)) - .copied() - .ok_or_else(|| { - format!( - "terrain LOD0 shadow face {face_index} references unknown vertex {vertex_index}" - ) - })?; - if !position.iter().all(|value| value.is_finite()) { - return Err(format!( - "terrain LOD0 shadow face {face_index} has a non-finite vertex {vertex_index}" - )); - } - Ok(position) +) -> Result<[[f32; 3]; 3], String> { + let face = terrain + .faces + .get(face_index) + .ok_or_else(|| format!("terrain LOD assignment references missing face {face_index}"))?; + let [first, second, third] = face.vertices; + Ok([ + terrain + .positions + .get(usize::from(first)) + .copied() + .ok_or_else(|| { + format!("terrain face {face_index} references missing vertex {first}") + })?, + terrain + .positions + .get(usize::from(second)) + .copied() + .ok_or_else(|| { + format!("terrain face {face_index} references missing vertex {second}") + })?, + terrain + .positions + .get(usize::from(third)) + .copied() + .ok_or_else(|| { + format!("terrain face {face_index} references missing vertex {third}") + })?, + ]) } fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle> { @@ -1709,46 +2164,205 @@ fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle> .collect() } -fn expand_shadow_sphere_to_triangles( - sphere: &mut ShadowSphere, +fn static_mesh_shadow_triangles_in_index_range( + mesh: &VulkanStaticMesh, + first_index: usize, + index_count: usize, +) -> Result<Vec<ShadowTriangle>, String> { + if index_count % 3 != 0 { + return Err(format!( + "dynamic shadow index range {first_index}..{} is not triangle-aligned", + first_index.saturating_add(index_count) + )); + } + let end = first_index + .checked_add(index_count) + .filter(|end| *end <= mesh.indices.len()) + .ok_or_else(|| { + format!( + "dynamic shadow index range {first_index}..{} is out of bounds", + first_index.saturating_add(index_count) + ) + })?; + mesh.indices[first_index..end] + .chunks_exact(3) + .map(|triangle| { + let positions = + triangle + .iter() + .map(|index| { + mesh.vertices + .get(usize::try_from(*index).map_err(|_| { + "dynamic shadow vertex index exceeds usize".to_string() + })?) + .map(|vertex| vertex.position) + .ok_or_else(|| { + format!("dynamic shadow vertex index {index} is out of bounds") + }) + }) + .collect::<Result<Vec<_>, _>>()?; + let positions: [[f32; 3]; 3] = positions + .try_into() + .map_err(|_| "dynamic shadow triangle did not have three vertices".to_string())?; + if !positions.iter().flatten().all(|value| value.is_finite()) { + return Err("dynamic shadow triangle contains a non-finite position".to_string()); + } + Ok(shadow_triangle(positions, 0)) + }) + .collect() +} + +fn refresh_dynamic_shadow_casters( + casters: &mut [ShadowCaster], + dynamic_casters: &[DynamicShadowCaster], + mesh: &VulkanStaticMesh, + pose_buffers: &[Vec<Option<NodePoseBuffer>>], + mount_poses: &[Vec<Option<VulkanNodePose>>], +) -> Result<(), String> { + for dynamic in dynamic_casters { + let caster = casters.get_mut(dynamic.caster_index).ok_or_else(|| { + format!( + "dynamic shadow caster {} is outside the aggregate caster table", + dynamic.caster_index + ) + })?; + let mut triangles = Vec::new(); + for &range_index in &dynamic.component_draw_range_indices { + let range = mesh.draw_ranges.get(range_index).ok_or_else(|| { + format!( + "dynamic shadow caster {} references missing draw range {range_index}", + dynamic.caster_index + ) + })?; + triangles.extend(static_mesh_shadow_triangles_in_index_range( + mesh, + usize::try_from(range.first_index) + .map_err(|_| "dynamic shadow draw range index exceeds usize".to_string())?, + usize::try_from(range.index_count) + .map_err(|_| "dynamic shadow draw range count exceeds usize".to_string())?, + )?); + } + if triangles.is_empty() { + return Err(format!( + "dynamic shadow caster {} lost its component triangles", + dynamic.caster_index + )); + } + let component_spheres = dynamic + .component_spheres + .iter() + .filter_map(|source| { + let mount_pose = if source.mounted { + mount_poses + .get(dynamic.object_index) + .and_then(|items| items.get(source.component_index)) + .copied() + .flatten() + } else { + None + }; + if source.mounted && mount_pose.is_none() { + return None; + } + let node1_pose = if source.mounted { + pose_buffers + .get(dynamic.object_index) + .and_then(|items| items.get(source.component_index)) + .and_then(Option::as_ref) + .and_then(|hierarchy| { + node38_pose_relative_to_root_from_hierarchy(hierarchy, 1) + }) + } else { + None + }; + mounted_shadow_sphere(source.center, source.radius, mount_pose, node1_pose) + }) + .collect::<Vec<_>>(); + if component_spheres.len() != dynamic.component_spheres.len() { + return Err(format!( + "dynamic shadow caster {} lost one or more component sphere poses", + dynamic.caster_index + )); + } + let mut refreshed = aggregate_shadow_caster( + caster.id, + caster.native_kind, + dynamic.root_transform, + dynamic.owner_scale, + triangles, + &component_spheres, + ) + .ok_or_else(|| { + format!( + "dynamic shadow caster {} could not be rebuilt from current component poses", + dynamic.caster_index + ) + })?; + // shortcut: animated native sphere parity is unverified. Rebuild from + // the startup native bound, the current native bound, and current + // triangles so the result is conservative and independent of history. + refreshed.sphere = shadow_sphere_enclosing_current_geometry( + dynamic.initial_sphere, + refreshed.sphere, + &refreshed.triangles, + )?; + *caster = refreshed; + } + Ok(()) +} + +fn shadow_sphere_enclosing_current_geometry( + baseline: ShadowSphere, + current_native: ShadowSphere, triangles: &[ShadowTriangle], -) -> Result<bool, String> { +) -> Result<ShadowSphere, String> { + let mut sphere = baseline; if !sphere.radius.is_finite() || sphere.radius < 0.0 || !sphere.center.iter().all(|value| value.is_finite()) + || !current_native.radius.is_finite() + || current_native.radius < 0.0 + || !current_native.center.iter().all(|value| value.is_finite()) { return Err("animated shadow caster has an invalid bounding sphere".to_string()); } - let mut changed = false; + + let offset = sub3(current_native.center, sphere.center); + let distance = offset[0].hypot(offset[1]).hypot(offset[2]); + if !distance.is_finite() { + return Err("animated shadow sphere distance is non-finite".to_string()); + } + let required = distance + current_native.radius; + if !required.is_finite() { + return Err("animated shadow sphere radius is non-finite".to_string()); + } + if current_native.radius >= distance + sphere.radius { + sphere = current_native; + } else if required > sphere.radius { + let next_radius = sphere.radius + 0.5 * (required - sphere.radius); + let shift = scale3(offset, (next_radius - sphere.radius) / distance); + sphere.center = add3(sphere.center, shift); + sphere.radius = next_radius; + } + for point in triangles.iter().flat_map(|triangle| triangle.positions) { if !point.iter().all(|value| value.is_finite()) { return Err("animated shadow triangle has a non-finite position".to_string()); } let offset = sub3(point, sphere.center); - let distance = length3(offset) - .ok_or_else(|| "animated shadow point distance is non-finite".to_string())?; + let distance = offset[0].hypot(offset[1]).hypot(offset[2]); + if !distance.is_finite() { + return Err("animated shadow point distance is non-finite".to_string()); + } if distance <= sphere.radius { continue; } - let next_radius = 0.5 * (sphere.radius + distance); - let center_shift = if distance > f32::EPSILON { - scale3(offset, (next_radius - sphere.radius) / distance) - } else { - [0.0; 3] - }; + let next_radius = sphere.radius + 0.5 * (distance - sphere.radius); + let center_shift = scale3(offset, (next_radius - sphere.radius) / distance); sphere.center = add3(sphere.center, center_shift); sphere.radius = next_radius; - changed = true; } - Ok(changed) -} -fn shadow_sphere_enclosing_triangles( - baseline: ShadowSphere, - triangles: &[ShadowTriangle], -) -> Result<ShadowSphere, String> { - let mut sphere = baseline; - expand_shadow_sphere_to_triangles(&mut sphere, triangles)?; Ok(sphere) } @@ -1858,6 +2472,7 @@ fn shadow_lights( sky_frame: Option<&SkyFrame<'_>>, environment_frame: Option<&EnvironmentFrame>, renderer: &VulkanSmokeRenderer, + control_point_lights: &[(VulkanPointLight, u32)], ) -> Vec<ShadowLight> { let mut lights = Vec::new(); if let Some(sky_frame) = sky_frame { @@ -1919,6 +2534,18 @@ fn shadow_lights( flags: 0, }); } + lights.extend( + control_point_lights + .iter() + .map(|(light, flags)| ShadowLight::Point { + position: light.position, + rgb: light.rgb, + range: light.range, + coefficients: light.coefficients, + active: light.active, + flags: *flags, + }), + ); lights } @@ -2172,6 +2799,33 @@ fn reorder_draw_ranges( Ok(remap) } +fn remap_dynamic_shadow_caster_draw_ranges( + dynamic_casters: &mut [DynamicShadowCaster], + range_remap: &[usize], +) -> Result<(), String> { + let remapped = dynamic_casters + .iter() + .map(|dynamic| { + dynamic + .component_draw_range_indices + .iter() + .map(|&range_index| { + range_remap.get(range_index).copied().ok_or_else(|| { + format!( + "dynamic shadow caster {} references missing pre-reorder draw range {range_index}", + dynamic.caster_index + ) + }) + }) + .collect::<Result<Vec<_>, _>>() + }) + .collect::<Result<Vec<_>, _>>()?; + for (dynamic, ranges) in dynamic_casters.iter_mut().zip(remapped) { + dynamic.component_draw_range_indices = ranges; + } + Ok(()) +} + fn quad_indices(slot_count: usize) -> Result<Vec<u32>, String> { let mut indices = Vec::with_capacity( slot_count @@ -2268,8 +2922,11 @@ impl EnvironmentGpuScene { fn new( mesh: &mut VulkanStaticMesh, native_handedness_by_range: &mut HashMap<usize, i8>, + type16_present_ranges: &mut HashSet<usize>, materials: &mut Vec<VulkanStaticMaterial>, preview_material_animations: &mut Vec<MaterialAnimationBinding>, + ambient_effect_slots: &mut [AmbientEffectGpuSlot], + dynamic_shadow_casters: &mut [DynamicShadowCaster], sun_occlusion_range_indices: &mut Vec<usize>, environment_materials: &SkyMaterials, material_assets: &HashMap<String, EnvironmentMaterialAsset>, @@ -2695,6 +3352,16 @@ impl EnvironmentGpuScene { remapped_handedness.insert(new_range_index, handedness); } *native_handedness_by_range = remapped_handedness; + let mut remapped_type16_present_ranges = + HashSet::with_capacity(type16_present_ranges.len()); + for old_range_index in std::mem::take(type16_present_ranges) { + let new_range_index = remap.get(old_range_index).copied().ok_or_else(|| { + format!("type-16 MSH draw range remap is missing range {old_range_index}") + })?; + remapped_type16_present_ranges.insert(new_range_index); + } + *type16_present_ranges = remapped_type16_present_ranges; + remap_dynamic_shadow_caster_draw_ranges(dynamic_shadow_casters, &remap)?; for layer in &mut scene.sky_layers { layer.range.range_index = *remap .get(layer.range.range_index) @@ -2716,6 +3383,16 @@ impl EnvironmentGpuScene { .ok_or_else(|| "lightning draw range remap is out of bounds".to_string())?; remap_material_animation_ranges(&mut scene.material_animations, &remap)?; remap_material_animation_ranges(preview_material_animations, &remap)?; + for slot in ambient_effect_slots { + slot.range.range_index = *remap + .get(slot.range.range_index) + .ok_or_else(|| "ambient FX draw range remap is out of bounds".to_string())?; + for range in &mut slot.additional_particle_ranges { + range.range_index = *remap.get(range.range_index).ok_or_else(|| { + "ambient FX particle draw range remap is out of bounds".to_string() + })?; + } + } for range_index in sun_occlusion_range_indices { *range_index = *remap .get(*range_index) @@ -3256,8 +3933,8 @@ fn segment_intersects_triangle( distance.is_finite() && (0.0..=1.0).contains(&distance) } -/// Replays the native draw-item callback: one whole indexed batch is keyed by -/// the camera distance to its first indexed vertex. +/// Replays Terrain's native packet callback: a draw range is keyed by the +/// camera distance to its first indexed vertex. fn first_indexed_vertex_distance( mesh: &VulkanStaticMesh, range_index: usize, @@ -3486,9 +4163,20 @@ fn lightning_quad_vertices( /// per-frame light/fog block. The sky mesh and weather primitives stay on the /// same update clock, so camera movement cannot leave environment audio or /// lighting one frame behind. -#[derive(Clone, Debug, PartialEq)] +struct DynamicEnvironment { + schedule: TypedAtmosphere, + sky: SkySystem, + environment: EnvironmentSystem, + materials: SkyMaterials, + material_assets: HashMap<String, EnvironmentMaterialAsset>, + schedule_offset_seconds: f32, + time_seconds: f32, + fixed_schedule_seconds: Option<f32>, + fixed_phase_reported: bool, +} + struct DynamicEnvironmentFrame { - atmosphere: fparkan_fx::atmosphere::AtmosphereFrame, + atmosphere: AtmosphereFrame, sky_mesh: SkyMesh, environment_frame: EnvironmentFrame, absolute_time_seconds: f32, @@ -3510,26 +4198,28 @@ fn environment_camera_for_preview( .with_viewport(viewport) } -struct DynamicEnvironment { - schedule: TypedAtmosphere, - sky: SkySystem, - environment: EnvironmentSystem, - materials: SkyMaterials, - material_assets: HashMap<String, EnvironmentMaterialAsset>, - schedule_offset_seconds: f32, - time_seconds: f32, - fixed_schedule_seconds: Option<f32>, - fixed_phase_reported: bool, +struct SceneFrameUpdate { + clock: FrameClockSample, + camera: PreviewCameraFrame, + viewport: [f32; 2], + aspect: f32, + control_point_lights: Vec<(VulkanPointLight, u32)>, + control_vulkan_lights: Vec<VulkanPointLight>, + effect_updates: Vec<AmbientEffectGeometryUpdate>, + world_sort_keys: Vec<(usize, f32)>, + ambient_sort_keys: Vec<(usize, f32)>, + material_update: MaterialFrameUpdate, + environment_material_update: Option<MaterialFrameUpdate>, + environment: Option<DynamicEnvironmentFrame>, } impl DynamicEnvironment { - fn update( + fn update_cpu( &mut self, dt_seconds: f32, camera: &PreviewCameraFrame, aspect: f32, viewport: [f32; 2], - renderer: &mut VulkanSmokeRenderer, mut audio: Option<&mut audio::GameAudio>, ) -> Result<DynamicEnvironmentFrame, String> { let dt_seconds = if dt_seconds.is_finite() { @@ -3570,31 +4260,6 @@ impl DynamicEnvironment { .map_err(|error| format!("update ambient variation: {error}"))?; } - let mut uniforms = renderer.frame_uniforms(); - uniforms.clip_from_world = camera.clip_from_world; - for (target, source) in uniforms - .directional_lights - .iter_mut() - .zip(sky_frame.directional_lights) - { - target.direction = source.direction; - target.rgb = source.color; - target.coefficients = [0.0, 0.0, 1.0]; - target.active = source.active; - } - uniforms.lighting_floor = sky_frame.lighting_floor; - let fog_rgb = sky_frame - .screen_gradient(camera.native_sky_heading(), [0.0; 3]) - .color - .rgba(); - uniforms.fog_color = [fog_rgb[0], fog_rgb[1], fog_rgb[2]]; - uniforms.fog_start = sky_frame.fog.start.max(0.0); - uniforms.fog_end = sky_frame.fog.end.max(uniforms.fog_start); - uniforms.camera_position = camera.position; - renderer - .set_frame_uniforms(uniforms) - .map_err(|error| format!("update sky frame uniforms: {error}"))?; - let fx_camera = environment_camera_for_preview(camera, aspect, viewport); let environment_frame = self.environment.update_atmosphere_with_materials( dt_seconds, @@ -3602,22 +4267,6 @@ impl DynamicEnvironment { &self.materials, fx_camera, ); - let mut uniforms = renderer.frame_uniforms(); - uniforms.point_light = environment_frame - .point_lights - .iter() - .find(|light| light.active) - .map(|light| VulkanPointLight { - position: light.position, - rgb: [light.color[0], light.color[1], light.color[2]], - range: light.range, - coefficients: light.attenuation, - active: true, - }) - .unwrap_or_default(); - renderer - .set_frame_uniforms(uniforms) - .map_err(|error| format!("update environment point-light uniforms: {error}"))?; if let Some(audio) = audio.as_mut() { for sound in &environment_frame.sounds { audio @@ -3920,6 +4569,10 @@ fn scale3(value: [f32; 3], factor: f32) -> [f32; 3] { [value[0] * factor, value[1] * factor, value[2] * factor] } +fn multiply3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] * right[0], left[1] * right[1], left[2] * right[2]] +} + fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 { left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2])) } @@ -3960,20 +4613,22 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { struct StaticPreviewScene { mesh: VulkanStaticMesh, native_handedness_by_range: HashMap<usize, i8>, + type16_present_ranges: HashSet<usize>, materials: Vec<VulkanStaticMaterial>, material_animations: Vec<MaterialAnimationBinding>, + ambient_effect_slots: Vec<AmbientEffectGpuSlot>, + control_point_light_slots: Vec<ControlPointLightSlot>, + component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>, + component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>, + dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>, + building_collision_bindings: Vec<BuildingCollisionBinding>, + control_animation_instances: Vec<Vec<Option<ControlAnimationInstance>>>, + dynamic_shadow_casters: Vec<DynamicShadowCaster>, sun_occlusion_range_indices: Vec<usize>, shadow_casters: Vec<ShadowCaster>, - shadow_sphere_baselines: Vec<ShadowSphere>, shadow_receivers: Vec<ShadowTriangle>, shadow_material_index: usize, terrain: Arc<TerrainWorld>, - component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>, - component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>, - control_animation_instances: Vec<ControlAnimationInstance>, - dynamic_component_geometry: Vec<DynamicComponentGeometry>, - dynamic_component_mounts: Vec<DynamicComponentMount>, - shadow_caster_indices_by_object: Vec<Option<usize>>, camera: VulkanStaticCamera, view_camera: PreviewCameraFrame, captured_viewport: Option<CapturedViewport>, @@ -3983,33 +4638,245 @@ struct StaticPreviewScene { terrain_components: usize, } -#[derive(Clone, Debug)] +#[derive(Clone)] struct ControlAnimationInstance { - object_index: usize, - component_index: usize, - model: Arc<ModelAsset>, control: PreparedComponentControl, + model: ModelAsset, + gate_values: Vec<Option<f32>>, state: ControlInstanceState, + last_sampled_node_times: Vec<Option<f32>>, + reported_unsupported_fallback: bool, } -#[derive(Clone, Debug)] +#[derive(Clone)] struct DynamicComponentGeometry { + model: ModelAsset, + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + parent_mount: Option<(usize, usize)>, + vertex_start: usize, + vertex_count: usize, +} + +#[derive(Clone)] +struct BuildingCollisionBinding { object_index: usize, component_index: usize, + model_source: String, model: Arc<ModelAsset>, - transform: LegacyIron3dEulerTransform, - scale: [f32; 3], - vertex_start: usize, - vertex_count: usize, - shadow_triangle_range: Option<std::ops::Range<usize>>, + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + mounted: bool, + selected_nodes: Vec<(NodeId, SlotId)>, +} + +fn building_collision_binding( + object_index: usize, + component_index: usize, + model_source: String, + model: Arc<ModelAsset>, + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + mounted: bool, +) -> Result<BuildingCollisionBinding, String> { + let context = || { + format!( + "building collision object {object_index} component {component_index} model {model_source}" + ) + }; + if model.node_stride != 38 || model.node_count == 0 || model.animation.is_none() { + return Err(format!("{} has no complete Node38 pose data", context())); + } + if model.triangle_descriptors.is_none() { + return Err(format!("{} has no type-7 collision descriptors", context())); + } + if root_transform.try_row_major().is_none() + || !owner_scale.iter().all(|value| value.is_finite()) + || owner_scale.contains(&0.0) + { + return Err(format!("{} has an invalid world transform", context())); + } + let first_node = usize::from(mounted); + let mut selected_nodes = Vec::new(); + for node_index in first_node..model.node_count { + let node = NodeId( + u32::try_from(node_index) + .map_err(|_| format!("{} node index exceeds u32", context()))?, + ); + let Some(slot) = selected_slot(&model, node, Lod(0), Group(0)) else { + continue; + }; + let slot_index = usize::try_from(slot.0) + .map_err(|_| format!("{} node {} has an invalid slot id", context(), node.0))?; + let slot_data = model.slots.get(slot_index).ok_or_else(|| { + format!( + "{} node {} selects missing slot {}", + context(), + node.0, + slot.0 + ) + })?; + let batch_start = usize::from(slot_data.batch_start); + let batch_end = batch_start + .checked_add(usize::from(slot_data.batch_count)) + .ok_or_else(|| format!("{} node {} batch range overflows", context(), node.0))?; + let batches = model.batches.get(batch_start..batch_end).ok_or_else(|| { + format!( + "{} node {} batch range {batch_start}..{batch_end} is invalid", + context(), + node.0 + ) + })?; + if batches.iter().any(|batch| batch.index_count != 0) { + selected_nodes.push((node, slot)); + } + } + Ok(BuildingCollisionBinding { + object_index, + component_index, + model_source, + model, + root_transform, + owner_scale, + mounted, + selected_nodes, + }) +} + +fn building_collision_nodes( + binding: &BuildingCollisionBinding, + pose_buffer: Option<&NodePoseBuffer>, + mount_pose: Option<VulkanNodePose>, +) -> Result<Vec<BuildingMeshNode>, String> { + let context = || { + format!( + "building collision object {} component {} model {}", + binding.object_index, binding.component_index, binding.model_source + ) + }; + if binding.mounted != mount_pose.is_some() { + return Err(format!( + "{} has {} mount pose, expected {}", + context(), + if mount_pose.is_some() { "a" } else { "no" }, + if binding.mounted { "a" } else { "no" } + )); + } + binding + .selected_nodes + .iter() + .map(|(node, slot)| { + let node_index = + usize::try_from(node.0).map_err(|_| format!("{} node id is invalid", context()))?; + let pose = match (pose_buffer, binding.mounted) { + (Some(hierarchy), true) => { + node38_pose_relative_to_root_from_hierarchy(hierarchy, node_index) + } + (Some(hierarchy), false) => node38_pose_from_hierarchy(hierarchy, node_index), + (None, true) => node38_pose_relative_to_root(&binding.model, None, node_index), + (None, false) => node38_pose(&binding.model, None, node_index), + } + .ok_or_else(|| format!("{} node {} has no current Node38 pose", context(), node.0))?; + let world_from_node = component_node_world_matrix( + binding.root_transform, + binding.owner_scale, + mount_pose, + pose, + ) + .ok_or_else(|| format!("{} node {} has an invalid world matrix", context(), node.0))?; + Ok(BuildingMeshNode { + node: *node, + slot: *slot, + world_from_node, + }) + }) + .collect() +} + +fn component_node_world_matrix( + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + mount_pose: Option<VulkanNodePose>, + node_pose: VulkanNodePose, +) -> Option<[f32; 16]> { + if !owner_scale + .iter() + .chain(node_pose.translation.iter()) + .chain(node_pose.rotation.iter()) + .chain(mount_pose.iter().flat_map(|pose| pose.translation.iter())) + .chain(mount_pose.iter().flat_map(|pose| pose.rotation.iter())) + .all(|value| value.is_finite()) + { + return None; + } + let pose_matrix = |pose: VulkanNodePose| { + let [x, y, z] = [ + rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation), + rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation), + rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation), + ]; + [ + x[0], + y[0], + z[0], + pose.translation[0], + x[1], + y[1], + z[1], + pose.translation[1], + x[2], + y[2], + z[2], + pose.translation[2], + 0.0, + 0.0, + 0.0, + 1.0, + ] + }; + let scale = [ + owner_scale[0], + 0.0, + 0.0, + 0.0, + 0.0, + owner_scale[1], + 0.0, + 0.0, + 0.0, + 0.0, + owner_scale[2], + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + ]; + let mut world = multiply_row_major(root_transform.try_row_major()?, scale); + if let Some(mount) = mount_pose { + world = multiply_row_major(world, pose_matrix(mount)); + } + world = multiply_row_major(world, pose_matrix(node_pose)); + world.iter().all(|value| value.is_finite()).then_some(world) } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct DynamicComponentMount { +#[derive(Clone, Debug)] +struct DynamicShadowCaster { + caster_index: usize, object_index: usize, + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + initial_sphere: ShadowSphere, + component_draw_range_indices: Vec<usize>, + component_spheres: Vec<DynamicShadowSphereSource>, +} + +#[derive(Clone, Copy, Debug)] +struct DynamicShadowSphereSource { component_index: usize, - parent_index: usize, - socket: usize, + center: [f32; 3], + radius: f32, + mounted: bool, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -4100,6 +4967,1939 @@ fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNod } } +fn control_type4_local_matrix( + points: [&PreparedControlPoint; 3], + owner_scale: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + if !owner_scale.iter().all(|value| value.is_finite()) { + return None; + } + let positions = points.map(|point| multiply3(point.position, owner_scale)); + let directions = points.map(|point| multiply3(point.direction, owner_scale)); + if !positions + .iter() + .flatten() + .chain(directions.iter().flatten()) + .all(|value| value.is_finite()) + { + return None; + } + let reciprocal_three = f32::from_bits(0x3EAA_AAAB); + let translation = std::array::from_fn(|axis| { + (positions[0][axis] + positions[1][axis] + positions[2][axis]) * reciprocal_three + }); + if !translation.iter().all(|value| value.is_finite()) { + return None; + } + + let [d0, d1, d2] = directions; + if d0 == d1 && d1 == d2 { + let magnitude = finite_length3(d0)?; + if magnitude <= 0.0 { + return None; + } + let basis = control_3ef0_basis(scale3(d0, magnitude.recip()), translation)?; + let scaled = std::array::from_fn(|row| { + let mut result = basis[row]; + for column in 0..3 { + result[column] *= magnitude; + } + result + }); + return scaled + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(scaled); + } + let (s, a) = if d0 == d2 || d1 == d2 { + (d0, d1) + } else if d0 == d1 { + (d0, d2) + } else { + let matrix = control_matrix_from_columns(d0, d1, d2, translation); + return matrix + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(matrix); + }; + + // In a repeated-direction row d0 is always the native S vector; the + // other vector selected above is A for helper 0x4070. + let cross = cross3(a, s); + let cross_length = finite_length3(cross)?; + if cross_length <= 0.0 { + return control_3ef0_basis(s, translation); + } + let u = scale3(cross, cross_length.recip()); + let v = cross3(s, u); + let matrix = control_matrix_from_columns(s, u, v, translation); + matrix + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(matrix) +} + +fn control_3ef0_basis(axis: [f32; 3], translation: [f32; 3]) -> Option<[[f32; 4]; 4]> { + if !axis + .iter() + .chain(translation.iter()) + .all(|value| value.is_finite()) + { + return None; + } + let mut u = if axis[0] == 0.0 { + [1.0, 0.0, 0.0] + } else if axis[1] == 0.0 { + [0.0, 1.0, 0.0] + } else { + [-axis[1], axis[0], 0.0] + }; + if axis[2] != 0.0 { + let u_length = finite_length3(u)?; + if u_length <= 0.0 { + return None; + } + u = scale3(u, u_length.recip()); + } + let v = cross3(axis, u); + let matrix = control_matrix_from_columns(axis, u, v, translation); + matrix + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(matrix) +} + +fn control_matrix_from_columns( + first: [f32; 3], + second: [f32; 3], + third: [f32; 3], + translation: [f32; 3], +) -> [[f32; 4]; 4] { + [ + [first[0], second[0], third[0], translation[0]], + [first[1], second[1], third[1], translation[1]], + [first[2], second[2], third[2], translation[2]], + [0.0, 0.0, 0.0, 1.0], + ] +} + +fn finite_length3(value: [f32; 3]) -> Option<f32> { + let length = dot3(value, value).sqrt(); + length.is_finite().then_some(length) +} + +fn control_type4_effect_local_matrix( + points: [&PreparedControlPoint; 3], + effect_scale: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + if !effect_scale.iter().all(|value| value.is_finite()) { + return None; + } + let mut local = control_type4_local_matrix(points, [1.0; 3])?; + for (column, scale) in effect_scale.into_iter().enumerate() { + for row in &mut local[..3] { + row[column] *= scale; + } + } + local + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(local) +} + +fn control_effect_owner_node_pose( + hierarchy: Option<&NodePoseBuffer>, + component_is_mounted: bool, + owner_node_index: usize, +) -> Option<VulkanNodePose> { + match hierarchy { + Some(hierarchy) if component_is_mounted => { + node38_pose_relative_to_root_from_hierarchy(hierarchy, owner_node_index) + } + Some(hierarchy) => node38_pose_from_hierarchy(hierarchy, owner_node_index), + None if owner_node_index == 0 => Some(VulkanNodePose { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, 1.0], + }), + None => None, + } +} + +fn node_pose_row_major(pose: VulkanNodePose) -> [[f32; 4]; 4] { + let basis = [ + rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation), + rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation), + rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation), + ]; + control_matrix_from_columns(basis[0], basis[1], basis[2], pose.translation) +} + +fn component_mount_matrix(mount_pose: VulkanNodePose) -> [[f32; 4]; 4] { + node_pose_row_major(mount_pose) +} + +fn component_scale_matrix(scale: [f32; 3]) -> [[f32; 4]; 4] { + [ + [scale[0], 0.0, 0.0, 0.0], + [0.0, scale[1], 0.0, 0.0], + [0.0, 0.0, scale[2], 0.0], + [0.0, 0.0, 0.0, 1.0], + ] +} + +fn matrix_to_row_major(matrix: [[f32; 4]; 4]) -> [f32; 16] { + std::array::from_fn(|index| matrix[index / 4][index % 4]) +} + +fn control_type4_world_from_effect( + local_from_effect: [[f32; 4]; 4], + owner_scale: [f32; 3], + root_transform: LegacyIron3dEulerTransform, + mount_pose: Option<VulkanNodePose>, + owner_node_pose: VulkanNodePose, +) -> Option<[[f32; 4]; 4]> { + if !owner_scale + .iter() + .chain(local_from_effect.iter().flatten()) + .chain(owner_node_pose.translation.iter()) + .chain(owner_node_pose.rotation.iter()) + .all(|value| value.is_finite()) + { + return None; + } + let root = root_transform.try_row_major()?; + let mut world_flat = multiply_row_major( + root, + matrix_to_row_major(component_scale_matrix(owner_scale)), + ); + if let Some(mount) = mount_pose { + world_flat = multiply_row_major( + world_flat, + matrix_to_row_major(component_mount_matrix(mount)), + ); + } + world_flat = multiply_row_major( + world_flat, + matrix_to_row_major(node_pose_row_major(owner_node_pose)), + ); + world_flat = multiply_row_major(world_flat, matrix_to_row_major(local_from_effect)); + let world = + std::array::from_fn(|row| std::array::from_fn(|column| world_flat[row * 4 + column])); + world + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(world) +} + +fn control_type4_world_from_effect_with_random_shift( + mut local_from_effect: [[f32; 4]; 4], + random_shift: [f32; 3], + owner_scale: [f32; 3], + root_transform: LegacyIron3dEulerTransform, + mount_pose: Option<VulkanNodePose>, + owner_node_pose: VulkanNodePose, +) -> Option<[[f32; 4]; 4]> { + for (axis, value) in random_shift.into_iter().enumerate() { + local_from_effect[axis][3] += value; + } + control_type4_world_from_effect( + local_from_effect, + owner_scale, + root_transform, + mount_pose, + owner_node_pose, + ) +} + +#[cfg(test)] +fn ambient_effect_world_from_component_pose( + slot: &AmbientEffectGpuSlot, + component_pose_buffers: &[Vec<Option<NodePoseBuffer>>], +) -> Option<[[f32; 4]; 4]> { + ambient_effect_world_from_component_pose_with_random_shift( + slot, + component_pose_buffers, + [0.0; 3], + ) +} + +fn ambient_effect_world_from_component_pose_with_random_shift( + slot: &AmbientEffectGpuSlot, + component_pose_buffers: &[Vec<Option<NodePoseBuffer>>], + random_shift: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + let hierarchy = component_pose_buffers + .get(slot.owner_object_index) + .and_then(|components| components.get(slot.owner_component_index)) + .and_then(Option::as_ref); + let owner_node_pose = control_effect_owner_node_pose( + hierarchy, + slot.owner_component_is_mounted, + slot.owner_node_index, + )?; + control_type4_world_from_effect_with_random_shift( + slot.local_from_effect, + random_shift, + slot.owner_scale, + slot.root_transform, + slot.mount_pose, + owner_node_pose, + ) +} + +fn control_point_light_world_from_component_pose_with_random_shift( + slot: &ControlPointLightSlot, + component_pose_buffers: &[Vec<Option<NodePoseBuffer>>], + random_shift: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + let hierarchy = component_pose_buffers + .get(slot.owner_object_index) + .and_then(|components| components.get(slot.owner_component_index)) + .and_then(Option::as_ref); + let owner_node_pose = control_effect_owner_node_pose( + hierarchy, + slot.owner_component_is_mounted, + slot.owner_node_index, + )?; + control_type4_world_from_effect_with_random_shift( + slot.local_from_effect, + random_shift, + slot.owner_scale, + slot.root_transform, + slot.mount_pose, + owner_node_pose, + ) +} + +fn update_control_point_lights( + slots: &mut [ControlPointLightSlot], + component_pose_buffers: &[Vec<Option<NodePoseBuffer>>], + component_mount_poses: &[Vec<Option<VulkanNodePose>>], + elapsed_seconds: f32, + random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>, + emit_diagnostic: bool, +) -> Vec<(VulkanPointLight, u32)> { + let mut lights = Vec::new(); + for slot in slots { + if slot.owner_component_is_mounted { + slot.mount_pose = component_mount_poses + .get(slot.owner_object_index) + .and_then(|components| components.get(slot.owner_component_index)) + .copied() + .flatten(); + } + let random_sample = random_samples + .get(&slot.random_instance_key) + .copied() + .unwrap_or_default(); + let Some(world_from_effect) = + control_point_light_world_from_component_pose_with_random_shift( + slot, + component_pose_buffers, + random_sample.accumulated_shift, + ) + else { + continue; + }; + slot.world_from_effect = world_from_effect; + let Some(phase) = ambient_effect_phase( + slot.time_mode, + slot.header_flags, + elapsed_seconds, + slot.duration_seconds, + ) + .map(|phase| native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)) else { + continue; + }; + let matrix = slot.world_from_effect; + let origin = [matrix[0][3], matrix[1][3], matrix[2][3]]; + let basis = [ + [matrix[0][0], matrix[1][0], matrix[2][0]], + [matrix[0][1], matrix[1][1], matrix[2][1]], + [matrix[0][2], matrix[1][2], matrix[2][2]], + ]; + let Some(frame) = evaluate_op1_point_light_with_basis(&slot.command, phase, origin, basis) + else { + continue; + }; + if !frame.active || frame.light_selector != 1 { + continue; + } + if emit_diagnostic { + eprintln!( + "control_point_light_detail object={} component={} table={} row={} command={} header_flags=0x{:08X} effect_type={} native_flags=0x{:08X} selector={} command_time={} phase={:.4} local_position_start={:?} local_position_end={:?} world_origin={:?} output_position={:?} rgb={:?} alpha={:.3} range={} attenuation={:?} effect_factor={}", + slot.owner_object_index, + slot.owner_component_index, + slot.random_instance_key.table_index, + slot.random_instance_key.row_index, + slot.command_index, + slot.header_flags, + frame.effect_type, + frame.native_flags, + frame.light_selector, + frame.command_time, + phase, + fx_command_vec3(&slot.command, 12), + fx_command_vec3(&slot.command, 24), + [matrix[0][3], matrix[1][3], matrix[2][3]], + frame.position, + frame.color, + frame.color[3], + frame.range, + frame.attenuation, + frame.effect_factor, + ); + } + lights.push(( + VulkanPointLight { + position: frame.position, + rgb: [frame.color[0], frame.color[1], frame.color[2]], + range: frame.range, + coefficients: frame.attenuation, + active: true, + }, + frame.native_flags, + )); + } + lights +} + +fn fx_command_vec3(command: &FxCommand, offset: usize) -> Option<[f32; 3]> { + let mut result = [0.0; 3]; + for (axis, value) in result.iter_mut().enumerate() { + let start = offset.checked_add(axis.checked_mul(4)?)?; + let bytes = command.raw_body.get(start..start.checked_add(4)?)?; + *value = f32::from_le_bytes(bytes.try_into().ok()?); + } + Some(result) +} + +fn report_control_point_lights_once( + reported: &mut bool, + control_clock_ms: u32, + target_clock_ms: Option<u32>, + environment_count: usize, + raw_control_lights: &[(VulkanPointLight, u32)], + ordinary_control_lights: &[VulkanPointLight], +) { + if *reported || std::env::var_os("FPARKAN_CONTROL_POINT_LIGHT_DIAGNOSTIC").is_none() { + return; + } + if target_clock_ms.is_some_and(|target| control_clock_ms < target) { + return; + } + eprintln!( + "control_point_lights clock_ms={control_clock_ms} environment_count={environment_count} control_raw_count={} control_ordinary_count={} raw_flags={:?}", + raw_control_lights.len(), + ordinary_control_lights.len(), + raw_control_lights.iter().map(|(_, flags)| *flags).collect::<Vec<_>>(), + ); + *reported = true; +} + +fn ordinary_control_point_lights( + raw_control_lights: &[(VulkanPointLight, u32)], +) -> Vec<VulkanPointLight> { + raw_control_lights + .iter() + .filter(|(light, flags)| light.active && flags & 0x8000_0000 == 0) + .map(|(light, _)| *light) + .collect() +} + +fn initial_native_effect_phase(header_flags: u32, phase: f32) -> f32 { + if header_flags & 0x80 != 0 { + 0.0 + } else { + phase + } +} + +fn native_command_phase(header_flags: u32, phase: f32, random_phase_jitter: f32) -> f32 { + let initial_phase = initial_native_effect_phase(header_flags, phase); + if initial_phase == -1.0 { + -1.0 + } else { + (initial_phase + + if header_flags & 1 != 0 { + random_phase_jitter + } else { + 0.0 + }) + .clamp(0.0, 1.0) + } +} + +fn control_row_points<'a>( + point_list: &'a PreparedControlPointList, + row: &PreparedControlEffectRow, +) -> Option<[&'a PreparedControlPoint; 3]> { + let mut result = Vec::with_capacity(3); + for offset in row.point_offsets { + result.push(point_list.points.get(usize::try_from(offset).ok()?)?); + } + result.try_into().ok() +} + +fn append_initial_control_effect_slots( + root: &Path, + mission_root: &MissionObjectDraft, + object_index: usize, + component_index: usize, + control: &PreparedComponentControl, + component_pose_buffer: Option<&NodePoseBuffer>, + mount_pose: Option<VulkanNodePose>, + mesh: &mut VulkanStaticMesh, + materials: &mut Vec<VulkanStaticMaterial>, + material_animations: &mut Vec<MaterialAnimationBinding>, + effect_documents: &mut HashMap<String, FxDocument>, + effect_materials: &mut HashMap<String, PreparedEffectMaterial>, + slots: &mut Vec<AmbientEffectGpuSlot>, + point_light_slots: &mut Vec<ControlPointLightSlot>, +) -> Result<(), String> { + let Some(point_list) = control.point_list.as_ref() else { + return Ok(()); + }; + for row in + selected_initial_control_effect_rows(control.initial_effect_group, &control.effect_section) + { + let group_index = row.table_index; + let archive = fixed_effect_field(&row.resource.archive); + let name = fixed_effect_field(&row.resource.name); + let points = control_row_points(point_list, row).ok_or_else(|| { + format!( + "Control group {group_index} row {} references a missing CPT point {:?}", + row.row_index, row.point_offsets + ) + })?; + let key = format!("{archive}/{name}"); + if !effect_documents.contains_key(&key) { + let bytes = load_resource_entry_bytes_from_root(root, &archive, &name) + .map_err(|error| format!("load Control FX {key}: {error}"))?; + let Ok(document) = decode_fxid(bytes) else { + continue; + }; + effect_documents.insert(key.clone(), document); + } + let document = effect_documents + .get(&key) + .ok_or_else(|| format!("Control FX {key} was not cached"))?; + let header = document.header(); + let duration_seconds = header.duration_seconds; + if !duration_seconds.is_finite() + || duration_seconds < 0.0 + || !matches!(header.time_mode, 0 | 1 | 2) + || (header.time_mode != 0 && duration_seconds <= 0.0) + || !header.phase_jitter.is_finite() + || !header.random_shift.iter().all(|value| value.is_finite()) + || !header.scale.iter().all(|value| value.is_finite()) + { + // Control can reference action effects whose header timing and + // scale conventions are outside this ambient subset. Leave them + // unexpanded until their lifetime and transform contract are + // understood rather than failing the whole mission preview. + continue; + } + let owner_node_index = + usize::try_from(points[0].raw_signed_field_04) + .ok() + .filter(|owner_node| { + component_pose_buffer + .is_some_and(|hierarchy| *owner_node < hierarchy.poses.len()) + || (*owner_node == 0 && component_pose_buffer.is_none()) + }); + let Some(owner_node_index) = owner_node_index else { + continue; + }; + let owner_node_pose = control_effect_owner_node_pose( + component_pose_buffer, + mount_pose.is_some(), + owner_node_index, + ); + let Some(owner_node_pose) = owner_node_pose else { + continue; + }; + let root_transform = LegacyIron3dEulerTransform { + translation: mission_root.position, + orientation_radians: mission_root.orientation_raw, + }; + let local_from_effect = control_type4_effect_local_matrix(points, header.scale) + .ok_or_else(|| format!("Control FX {key} has an invalid type-4 transform"))?; + let world_from_effect = control_type4_world_from_effect( + local_from_effect, + mission_root.scale, + root_transform, + mount_pose, + owner_node_pose, + ) + .ok_or_else(|| format!("Control FX {key} has an invalid owner transform"))?; + let control_source = format!( + "{}/{}", + control.source.archive.as_str(), + String::from_utf8_lossy(&control.source.name.0) + ); + let random_instance_key = ControlEffectInstanceKey { + object_index, + component_index, + table_index: row.table_index, + row_index: row.row_index, + }; + let random_seed = control_effect_instance_seed( + object_index, + component_index, + row.table_index, + row.row_index, + &control_source, + ); + + for (command_index, command) in document.commands().iter().enumerate() { + if ambient_effect_op1_supported(command) { + point_light_slots.push(ControlPointLightSlot { + time_mode: header.time_mode, + header_flags: header.flags, + random_instance_key, + random_seed, + phase_jitter: header.phase_jitter, + random_shift: header.random_shift, + duration_seconds, + owner_object_index: object_index, + owner_component_index: component_index, + owner_node_index, + owner_component_is_mounted: mount_pose.is_some(), + root_transform, + owner_scale: mission_root.scale, + mount_pose, + local_from_effect, + world_from_effect, + command_index, + command: command.clone(), + }); + continue; + } + let Some(kind) = ambient_effect_command_kind(command) else { + continue; + }; + if !ambient_effect_render_selector_allows_header( + header.flags, + CONTROL_COMPONENT_RENDER_SELECTOR, + ) { + continue; + } + let Some(reference) = command.resource_refs.first() else { + continue; + }; + let material_archive = String::from_utf8_lossy(reference.archive_name()).into_owned(); + let material_name = String::from_utf8_lossy(reference.resource_name()).into_owned(); + if material_archive.is_empty() || material_name.is_empty() { + continue; + } + let cache_key = format!("{material_archive}/{material_name}"); + if !effect_materials.contains_key(&cache_key) { + let _prepared = prepare_effect_material( + root, + &material_archive, + &material_name, + materials, + effect_materials, + )?; + } + let prepared = effect_materials + .get(&cache_key) + .ok_or_else(|| format!("FX MAT0 {cache_key} was not cached"))?; + if !ambient_effect_tint_supported( + &prepared.phase_native_flag_40, + &prepared.phase_directional_rgb, + &prepared.phase_additive_rgb, + ) { + continue; + } + let first_material = *prepared + .phase_material_indices + .first() + .ok_or_else(|| format!("FX MAT0 {cache_key} has no phase variants"))?; + let op9_mesh_template = + if kind == AmbientEffectKind::GenerBall && command.opcode == FxOpcode::Op9 { + let Some(template) = evaluate_f_gener_ball_mesh( + command, + command_index, + &world_from_effect, + 0.0, + 0.0, + [0.0; 3], + AMBIENT_EFFECT_NATIVE_CATEGORY, + ) else { + continue; + }; + Some(template) + } else { + None + }; + let op9_primitive_layout = op9_mesh_template + .as_ref() + .map(ambient_effect_mesh_primitive_layout) + .transpose()?; + let placeholder: [VulkanStaticVertex; 4] = std::array::from_fn(|index| { + environment_vertex_with_alpha( + [0.0; 3], + [1.0; 3], + 0.0, + [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]][index], + ) + }); + let pipeline_state = ambient_effect_pipeline_state(prepared.initial_pipeline_category) + .ok_or_else(|| { + format!( + "FX MAT0 {cache_key} has unsupported pipeline category {}", + prepared.initial_pipeline_category + ) + })?; + let pool_capacity = match kind { + AmbientEffectKind::SmokePool => ambient_effect_pool_capacity(command) + .ok_or_else(|| format!("FX Op8 {cache_key} has an invalid pool size"))?, + AmbientEffectKind::Op7ParticlePool => ambient_effect_op7_range_capacity(command) + .ok_or_else(|| format!("FX Op7 {cache_key} has an invalid particle pool"))?, + _ => 1, + }; + let range_capacity = op9_primitive_layout + .as_ref() + .map_or(pool_capacity, Vec::len); + let mut particle_ranges = Vec::with_capacity(range_capacity); + let mut particle_material_animation_indices = Vec::with_capacity(range_capacity); + for _ in 0..pool_capacity { + if let (Some(template), Some(primitives)) = + (&op9_mesh_template, &op9_primitive_layout) + { + for primitive in primitives { + let vertices = ambient_effect_mesh_primitive_vertices(template, *primitive); + let indices = ambient_effect_mesh_primitive_indices(template, *primitive)?; + let range = append_environment_range( + mesh, + first_material, + vertices, + indices, + pipeline_state, + )?; + let material_animation_index = append_effect_material_animation( + prepared, + range.range_index, + material_animations, + ); + particle_ranges.push(range); + particle_material_animation_indices.push(material_animation_index); + } + } else { + let range = append_environment_range( + mesh, + first_material, + placeholder.to_vec(), + vec![0, 1, 2, 2, 3, 0], + pipeline_state, + )?; + let material_animation_index = append_effect_material_animation( + prepared, + range.range_index, + material_animations, + ); + particle_ranges.push(range); + particle_material_animation_indices.push(material_animation_index); + } + } + let range = particle_ranges.remove(0); + let material_animation_index = particle_material_animation_indices.remove(0); + let smoke_emitter_state = (kind == AmbientEffectKind::SmokePool).then(|| { + let seed = ambient_effect_seed( + object_index, + component_index, + row.table_index, + row.row_index, + command_index, + &control_source, + ); + SmokeEmitterState::new(seed, seed, seed) + }); + let op7_emitter_state = (kind == AmbientEffectKind::Op7ParticlePool).then(|| { + let seed = ambient_effect_seed( + object_index, + component_index, + row.table_index, + row.row_index, + command_index, + &control_source, + ); + Op7EmitterState::new(seed, seed) + }); + slots.push(AmbientEffectGpuSlot { + kind, + time_mode: header.time_mode, + header_flags: header.flags, + random_instance_key, + random_seed, + phase_jitter: header.phase_jitter, + random_shift: header.random_shift, + owner_object_index: object_index, + owner_component_index: component_index, + owner_node_index, + owner_component_is_mounted: mount_pose.is_some(), + root_transform, + owner_scale: mission_root.scale, + mount_pose, + local_from_effect, + control_source: control_source.clone(), + control_table_index: row.table_index, + control_row_index: row.row_index, + control_point_indices: row.point_offsets, + control_point_owners: points.map(|point| point.raw_signed_field_04), + command: command.clone(), + command_index, + duration_seconds, + world_from_effect, + range, + additional_particle_ranges: particle_ranges, + material_animation_index, + additional_particle_material_animation_indices: particle_material_animation_indices, + smoke_emitter_state, + op7_emitter_state, + }); + } + } + Ok(()) +} + +fn selected_initial_control_effect_rows<'a>( + group_index: Option<usize>, + section: &'a PreparedControlEffectSection, +) -> Vec<&'a PreparedControlEffectRow> { + let (Some(group_index), PreparedControlEffectSection::Parsed(rows)) = (group_index, section) + else { + return Vec::new(); + }; + rows.iter() + .filter(|row| { + row.table_index == group_index && ambient_effect_row_unconditionally_active(row) + }) + .collect() +} + +fn ambient_effect_row_unconditionally_active(row: &PreparedControlEffectRow) -> bool { + // Control22C0 returns true when none of the predicate bits are set. The + // two row-chain bits have separate dispatch semantics and stay excluded. + row.raw_condition[0] & 0xF000_0000 == 0 +} + +fn ambient_effect_command_kind(command: &FxCommand) -> Option<AmbientEffectKind> { + let body = command.raw_body.as_ref(); + let mode = body + .get(..4) + .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four byte FX body mode"))); + let body_c4 = body + .get(0xc4..0xc8) + .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four byte FX C4 field"))); + match (command.opcode, mode, body_c4) { + (FxOpcode::Op3, Some(0), _) if command.raw_body.len() >= 0xC4 => { + Some(AmbientEffectKind::Op3Type0) + } + (FxOpcode::Op3, Some(1), _) if command.raw_body.len() >= 0xC4 => { + Some(AmbientEffectKind::Op3Type1) + } + (FxOpcode::Op3, Some(2), _) => Some(AmbientEffectKind::Op3Type2), + (FxOpcode::Op9, Some(2), Some(0..=2)) => Some(AmbientEffectKind::GenerBall), + (FxOpcode::Op7, _, _) if ambient_effect_op7_range_capacity(command).is_some() => { + Some(AmbientEffectKind::Op7ParticlePool) + } + (FxOpcode::Op8, _, _) if ambient_effect_pool_capacity(command).is_some() => { + Some(AmbientEffectKind::SmokePool) + } + (FxOpcode::Op4, Some(0), Some(denominator_bits)) + if f32::from_bits(denominator_bits).is_finite() + && f32::from_bits(denominator_bits) > 0.0 => + { + Some(AmbientEffectKind::GenerBall) + } + _ => None, + } +} + +const CONTROL_COMPONENT_RENDER_SELECTOR: u32 = 1; + +fn ambient_effect_render_selector_allows_header(header_flags: u32, render_selector: u32) -> bool { + // Effect.dll 0x7D10 suppresses header flag 0x800 only for selector zero. + // The native AniMesh component path was observed calling the manager with + // selector one while its 0xD00 f_smalllight_y entries were present. + render_selector != 0 || header_flags & 0x800 == 0 +} + +fn ambient_effect_op1_supported(command: &FxCommand) -> bool { + if command.opcode != FxOpcode::Op1 { + return false; + } + let Some(effect_type) = command + .raw_body + .get(..4) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + else { + return false; + }; + command.raw_body.len() >= 132 && matches!(effect_type, 1 | 2 | 5 | 6 | 7) +} + +fn ambient_effect_pool_capacity(command: &FxCommand) -> Option<usize> { + if command.opcode != FxOpcode::Op8 { + return None; + } + let pool_count = usize::try_from(u32::from_le_bytes( + command.raw_body.get(0x20..0x24)?.try_into().ok()?, + )) + .ok()?; + (1..=4096).contains(&pool_count).then_some(pool_count) +} + +fn ambient_effect_op7_range_capacity(command: &FxCommand) -> Option<usize> { + if command.opcode != FxOpcode::Op7 || command.raw_body.len() < 0xCC { + return None; + } + let read_u32 = |offset: usize| -> Option<u32> { + Some(u32::from_le_bytes( + command.raw_body.get(offset..offset + 4)?.try_into().ok()?, + )) + }; + let read_f32 = |offset| read_u32(offset).map(f32::from_bits); + // The evaluator uses the traced default world scalar of 10 for this + // Control path; the command's acceleration is supported when finite. + let material_phase_factor = read_f32(0x1C)?; + if !matches!(read_u32(0x00)?, 0 | 1) + || !material_phase_factor.is_finite() + || material_phase_factor == 0.0 + || !read_f32(0x88)?.is_finite() + { + return None; + } + let pool_count = usize::try_from(read_u32(0x20)?).ok()?; + let copies_per_group = usize::try_from(read_u32(0x24)?).ok()?; + let capacity = pool_count.checked_mul(copies_per_group)?; + (1..=4096).contains(&capacity).then_some(capacity) +} + +fn ambient_effect_seed( + object_index: usize, + component_index: usize, + table_index: usize, + row_index: usize, + command_index: usize, + source: &str, +) -> u32 { + let mut hash = 0x811C_9DC5_u32; + for byte in source.bytes().chain( + [ + object_index, + component_index, + table_index, + row_index, + command_index, + ] + .into_iter() + .flat_map(|value| (value as u64).to_le_bytes()), + ) { + hash = (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193); + } + if hash == 0 { + 1 + } else { + hash + } +} + +fn control_effect_instance_seed( + object_index: usize, + component_index: usize, + table_index: usize, + row_index: usize, + source: &str, +) -> u32 { + ambient_effect_seed( + object_index, + component_index, + table_index, + row_index, + 0, + source, + ) +} + +fn advance_control_effect_random_states( + states: &mut HashMap<ControlEffectInstanceKey, ControlEffectRandomState>, + ambient_slots: &[AmbientEffectGpuSlot], + point_light_slots: &[ControlPointLightSlot], + sample_id: SceneSampleId, + control_clock_ms: u32, + elapsed_seconds: f32, +) -> HashMap<ControlEffectInstanceKey, ControlEffectRandomSample> { + let mut specifications = HashMap::new(); + for slot in ambient_slots { + let initial_phase = ambient_effect_phase( + slot.time_mode, + slot.header_flags, + elapsed_seconds, + slot.duration_seconds, + ) + .map(|phase| initial_native_effect_phase(slot.header_flags, phase)) + .unwrap_or(-1.0); + specifications.entry(slot.random_instance_key).or_insert(( + slot.random_seed, + slot.header_flags, + slot.phase_jitter, + slot.random_shift, + initial_phase, + )); + } + for slot in point_light_slots { + let initial_phase = ambient_effect_phase( + slot.time_mode, + slot.header_flags, + elapsed_seconds, + slot.duration_seconds, + ) + .map(|phase| initial_native_effect_phase(slot.header_flags, phase)) + .unwrap_or(-1.0); + specifications.entry(slot.random_instance_key).or_insert(( + slot.random_seed, + slot.header_flags, + slot.phase_jitter, + slot.random_shift, + initial_phase, + )); + } + + specifications + .into_iter() + .map( + |(key, (seed, flags, phase_jitter, random_shift, initial_phase))| { + let state = states.entry(key).or_insert_with(|| { + ControlEffectRandomState::new(seed, flags, phase_jitter, random_shift) + }); + ( + key, + state.advance(sample_id, control_clock_ms, initial_phase), + ) + }, + ) + .collect() +} + +fn ambient_effect_ranges(slot: &AmbientEffectGpuSlot) -> Vec<(&EnvironmentGpuRange, usize)> { + std::iter::once((&slot.range, slot.material_animation_index)) + .chain( + slot.additional_particle_ranges + .iter() + .zip(&slot.additional_particle_material_animation_indices) + .map(|(range, &binding)| (range, binding)), + ) + .collect() +} + +fn ambient_effect_quad_center(vertices: &[[f32; 3]; 4]) -> [f32; 3] { + std::array::from_fn(|axis| vertices.iter().map(|vertex| vertex[axis]).sum::<f32>() / 4.0) +} + +fn ambient_effect_quad_size(vertices: &[[f32; 3]; 4]) -> [f32; 2] { + let edge_length = |a: [f32; 3], b: [f32; 3]| { + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() + }; + [ + edge_length(vertices[0], vertices[1]), + edge_length(vertices[1], vertices[2]), + ] +} + +fn ambient_effect_projected_bounds( + vertices: &[[f32; 3]; 4], + clip_from_world: [f32; 16], + viewport: [u32; 2], +) -> Option<[f32; 4]> { + let mut points = [[0.0; 2]; 4]; + for (index, vertex) in vertices.iter().enumerate() { + let clip = [ + vertex[0] * clip_from_world[0] + + vertex[1] * clip_from_world[4] + + vertex[2] * clip_from_world[8] + + clip_from_world[12], + vertex[0] * clip_from_world[1] + + vertex[1] * clip_from_world[5] + + vertex[2] * clip_from_world[9] + + clip_from_world[13], + vertex[0] * clip_from_world[2] + + vertex[1] * clip_from_world[6] + + vertex[2] * clip_from_world[10] + + clip_from_world[14], + vertex[0] * clip_from_world[3] + + vertex[1] * clip_from_world[7] + + vertex[2] * clip_from_world[11] + + clip_from_world[15], + ]; + if !clip.iter().all(|value| value.is_finite()) || clip[3] <= f32::EPSILON { + return None; + } + points[index] = [ + (clip[0] / clip[3] + 1.0) * 0.5 * viewport[0] as f32, + (clip[1] / clip[3] + 1.0) * 0.5 * viewport[1] as f32, + ]; + } + Some([ + points + .iter() + .map(|point| point[0]) + .fold(f32::INFINITY, f32::min), + points + .iter() + .map(|point| point[1]) + .fold(f32::INFINITY, f32::min), + points + .iter() + .map(|point| point[0]) + .fold(f32::NEG_INFINITY, f32::max), + points + .iter() + .map(|point| point[1]) + .fold(f32::NEG_INFINITY, f32::max), + ]) +} + +fn ambient_effect_range_for( + slot: &AmbientEffectGpuSlot, + range_index: usize, +) -> Option<&EnvironmentGpuRange> { + std::iter::once(&slot.range) + .chain(slot.additional_particle_ranges.iter()) + .find(|range| range.range_index == range_index) +} + +fn ambient_effect_pipeline_state(category: u8) -> Option<LegacyPipelineState> { + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly) + .with_material_category(category) +} + +fn ambient_effect_tint_supported( + phase_native_flag_40: &[bool], + phase_directional_rgb: &[[f32; 3]], + phase_additive_rgb: &[[f32; 3]], +) -> bool { + !phase_native_flag_40.is_empty() + && phase_native_flag_40.len() == phase_directional_rgb.len() + && phase_native_flag_40.len() == phase_additive_rgb.len() + && phase_native_flag_40.iter().all(|flag| !flag) + && phase_directional_rgb + .iter() + .zip(phase_additive_rgb) + .all(|(directional, additive)| { + directional + .iter() + .chain(additive) + .all(|channel| channel.is_finite() && (0.0..=1.0).contains(channel)) + && (0..3).all(|channel| additive[channel] > 0.0 || directional[channel] == 0.0) + }) +} + +fn fixed_effect_field(field: &[u8; 32]) -> String { + let length = field + .iter() + .position(|byte| *byte == 0) + .unwrap_or(field.len()); + String::from_utf8_lossy(&field[..length]).trim().to_owned() +} + +fn update_ambient_effect_geometry( + slots: &mut [AmbientEffectGpuSlot], + component_pose_buffers: &[Vec<Option<NodePoseBuffer>>], + mesh: &mut VulkanStaticMesh, + material_animations: &mut [MaterialAnimationBinding], + elapsed_seconds: f32, + random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>, + camera_world: [f32; 3], +) -> Result<Vec<AmbientEffectGeometryUpdate>, String> { + let mut updates = Vec::with_capacity(slots.len()); + for slot in slots { + let random_sample = random_samples + .get(&slot.random_instance_key) + .copied() + .unwrap_or_default(); + let Some(world_from_effect) = ambient_effect_world_from_component_pose_with_random_shift( + slot, + component_pose_buffers, + random_sample.accumulated_shift, + ) else { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + }; + slot.world_from_effect = world_from_effect; + let Some(phase) = ambient_effect_phase( + slot.time_mode, + slot.header_flags, + elapsed_seconds, + slot.duration_seconds, + ) + .map(|phase| native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)) else { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + }; + if slot.kind == AmbientEffectKind::GenerBall && slot.command.opcode == FxOpcode::Op9 { + let Some(frame) = evaluate_f_gener_ball_mesh( + &slot.command, + slot.command_index, + &slot.world_from_effect, + phase, + elapsed_seconds, + camera_world, + AMBIENT_EFFECT_NATIVE_CATEGORY, + ) else { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + }; + let primitives = ambient_effect_mesh_primitive_layout(&frame)?; + let ranges = ambient_effect_ranges(slot); + if ranges.len() != primitives.len() { + return Err(format!( + "ambient Op9 draw range count changed ({} != {})", + ranges.len(), + primitives.len() + )); + } + for (primitive, (range, material_animation_index)) in primitives.into_iter().zip(ranges) + { + let binding = material_animations + .get_mut(material_animation_index) + .ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?; + binding.normalized_phase_override = Some(frame.material_phase); + write_ambient_effect_mesh_primitive(mesh, range, &frame, primitive)?; + updates.push(AmbientEffectGeometryUpdate { + range_index: range.range_index, + index_count: if frame.active && frame.color[3] > 0.0 { + u32::try_from(primitive.index_count) + .map_err(|_| "ambient Op9 index count exceeds u32".to_string())? + } else { + 0 + }, + command_rgb: [frame.color[0], frame.color[1], frame.color[2]], + }); + } + continue; + } + let frames = match (slot.kind, slot.command.opcode) { + (AmbientEffectKind::Op3Type0, FxOpcode::Op3) => evaluate_op3_type0_command( + &slot.command, + slot.command_index, + &slot.world_from_effect, + elapsed_seconds, + phase, + camera_world, + ) + .into_iter() + .collect(), + (AmbientEffectKind::Op7ParticlePool, FxOpcode::Op7) => { + let Some(state) = slot.op7_emitter_state.as_mut() else { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + }; + let Some(frames) = evaluate_op7_emitter( + &slot.command, + slot.command_index, + &slot.world_from_effect, + camera_world, + phase, + elapsed_seconds, + Op7RenderContext { + detail_level: 0, + // At detail level zero, [0,1) cutoffs with zero bias + // are all visible. The native quality preset remains + // unresolved, but no particle is culled here. + visibility: Op7VisibilityProfile { + width: 1.0, + bias: 0.0, + }, + // Terrain.dll initializes the native world's world + // scalar to 10.0. An engine integration can supply the + // live value after it exposes the world setting. + world_scalar: 10.0, + }, + state, + ) else { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + }; + fx_material_speed_diagnostic(slot, 0xAC, elapsed_seconds, phase, &frames); + frames + } + (AmbientEffectKind::SmokePool, FxOpcode::Op8) => { + let Some(state) = slot.smoke_emitter_state.as_mut() else { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + }; + let frames = evaluate_op8_emitter( + &slot.command, + slot.command_index, + &slot.world_from_effect, + camera_world, + elapsed_seconds, + phase, + 0, + Op8DetailThresholdRange::new(1.0, 1.0) + .expect("ordered finite Op8 material threshold"), + state, + ) + .unwrap_or_default(); + smoke_fr_02_diagnostic(slot, elapsed_seconds, phase, &frames); + fx_material_speed_diagnostic(slot, 0xD4, elapsed_seconds, phase, &frames); + frames + } + (AmbientEffectKind::Op3Type1, FxOpcode::Op3) => evaluate_op3_type1_command( + &slot.command, + slot.command_index, + &slot.world_from_effect, + elapsed_seconds, + phase, + camera_world, + ) + .into_iter() + .collect(), + (AmbientEffectKind::Op3Type2, FxOpcode::Op3) => evaluate_op3_type2_command( + &slot.command, + slot.command_index, + &slot.world_from_effect, + elapsed_seconds, + phase, + 0, + ) + .into_iter() + .collect(), + (AmbientEffectKind::GenerBall, FxOpcode::Op4) => evaluate_f_gener_ball( + &slot.command, + slot.command_index, + &slot.world_from_effect, + phase, + elapsed_seconds, + camera_world, + ) + .into_iter() + .collect(), + _ => Vec::new(), + }; + let ranges = ambient_effect_ranges(slot); + if frames.is_empty() { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + } + let variable_pool = slot.kind == AmbientEffectKind::Op7ParticlePool; + let aligned_frames = align_ambient_effect_frames(frames, ranges.len(), variable_pool)?; + for ((range, material_animation_index), frame) in ranges.into_iter().zip(aligned_frames) { + if let Some(frame) = frame { + let binding = material_animations + .get_mut(material_animation_index) + .ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?; + binding.normalized_phase_override = Some(frame.material_phase); + write_ambient_effect_frame(mesh, range, &frame)?; + updates.push(AmbientEffectGeometryUpdate { + range_index: range.range_index, + index_count: if frame.active && frame.color[3] > 0.0 { + 6 + } else { + 0 + }, + command_rgb: [frame.color[0], frame.color[1], frame.color[2]], + }); + } else { + deactivate_ambient_effect_range( + range, + material_animation_index, + material_animations, + &mut updates, + )?; + } + } + } + Ok(updates) +} + +fn align_ambient_effect_frames( + frames: Vec<fparkan_fx::fx_visual::EffectQuadFrame>, + range_count: usize, + variable_pool: bool, +) -> Result<Vec<Option<fparkan_fx::fx_visual::EffectQuadFrame>>, String> { + if (variable_pool && frames.len() > range_count) + || (!variable_pool && frames.len() != range_count) + { + return Err(format!( + "ambient FX frame pool has {} particles but {range_count} draw ranges", + frames.len() + )); + } + let active_count = frames.len(); + let mut aligned: Vec<_> = frames.into_iter().map(Some).collect(); + aligned.extend(std::iter::repeat_with(|| None).take(range_count - active_count)); + Ok(aligned) +} + +fn deactivate_ambient_effect_slot( + slot: &AmbientEffectGpuSlot, + material_animations: &mut [MaterialAnimationBinding], + updates: &mut Vec<AmbientEffectGeometryUpdate>, +) -> Result<(), String> { + for (range, material_animation_index) in ambient_effect_ranges(slot) { + deactivate_ambient_effect_range( + range, + material_animation_index, + material_animations, + updates, + )?; + } + Ok(()) +} + +fn deactivate_ambient_effect_range( + range: &EnvironmentGpuRange, + material_animation_index: usize, + material_animations: &mut [MaterialAnimationBinding], + updates: &mut Vec<AmbientEffectGeometryUpdate>, +) -> Result<(), String> { + let binding = material_animations + .get_mut(material_animation_index) + .ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?; + binding.normalized_phase_override = Some(0.0); + updates.push(AmbientEffectGeometryUpdate { + range_index: range.range_index, + index_count: 0, + command_rgb: [0.0; 3], + }); + Ok(()) +} + +fn write_ambient_effect_frame( + mesh: &mut VulkanStaticMesh, + range: &EnvironmentGpuRange, + frame: &fparkan_fx::fx_visual::EffectQuadFrame, +) -> Result<(), String> { + let end = range + .vertex_start + .checked_add(range.vertex_capacity) + .ok_or_else(|| "ambient FX vertex range overflows".to_string())?; + let destination = mesh + .vertices + .get_mut(range.vertex_start..end) + .ok_or_else(|| "ambient FX vertex allocation is out of bounds".to_string())?; + if destination.len() != 4 { + return Err("ambient FX quad allocation changed after GPU preparation".to_string()); + } + for (index, target) in destination.iter_mut().enumerate() { + *target = environment_vertex_with_alpha( + frame.vertices[index], + [frame.color[0], frame.color[1], frame.color[2]], + frame.color[3].clamp(0.0, 1.0), + frame.uv[index], + ); + } + Ok(()) +} + +fn ambient_effect_mesh_primitive_layout( + frame: &fparkan_fx::fx_visual::EffectMeshFrame, +) -> Result<Vec<AmbientEffectMeshPrimitive>, String> { + if frame.vertices.len() != frame.uv.len() || frame.indices.is_empty() { + return Err("ambient Op9 mesh has mismatched vertices/UVs or no indices".to_string()); + } + let quad_count = frame + .indices + .len() + .checked_sub(frame.vertices.len()) + .filter(|difference| difference.is_multiple_of(2)) + .map(|difference| difference / 2) + .ok_or_else(|| "ambient Op9 mesh is not a native quad/cap layout".to_string())?; + let quad_vertex_count = quad_count + .checked_mul(4) + .ok_or_else(|| "ambient Op9 quad vertex count overflows".to_string())?; + let quad_index_count = quad_count + .checked_mul(6) + .ok_or_else(|| "ambient Op9 quad index count overflows".to_string())?; + let cap_vertex_count = frame + .vertices + .len() + .checked_sub(quad_vertex_count) + .filter(|count| count.is_multiple_of(3)) + .ok_or_else(|| "ambient Op9 mesh has an invalid cap vertex count".to_string())?; + let cap_count = cap_vertex_count / 3; + let cap_index_count = cap_count + .checked_mul(3) + .ok_or_else(|| "ambient Op9 cap index count overflows".to_string())?; + if quad_index_count + cap_index_count != frame.indices.len() { + return Err("ambient Op9 quad and cap index counts do not match the frame".to_string()); + } + + // The native generator appends all quads first, then its triangle cap fan. + let mut primitives = Vec::with_capacity(quad_count + cap_count); + for quad in 0..quad_count { + let vertex_start = quad * 4; + let index_start = quad * 6; + let base = u16::try_from(vertex_start) + .map_err(|_| "ambient Op9 quad vertex exceeds u16".to_string())?; + let expected = [base, base + 1, base + 2, base + 2, base + 3, base]; + if frame.indices.get(index_start..index_start + expected.len()) != Some(&expected) { + return Err("ambient Op9 quad topology changed from native ordering".to_string()); + } + primitives.push(AmbientEffectMeshPrimitive { + vertex_start, + vertex_count: 4, + index_start, + index_count: 6, + }); + } + for cap in 0..cap_count { + let vertex_start = quad_vertex_count + cap * 3; + let index_start = quad_index_count + cap * 3; + let base = u16::try_from(vertex_start) + .map_err(|_| "ambient Op9 cap vertex exceeds u16".to_string())?; + let expected = [base, base + 1, base + 2]; + if frame.indices.get(index_start..index_start + expected.len()) != Some(&expected) { + return Err("ambient Op9 cap topology changed from native ordering".to_string()); + } + primitives.push(AmbientEffectMeshPrimitive { + vertex_start, + vertex_count: 3, + index_start, + index_count: 3, + }); + } + if primitives.is_empty() { + return Err("ambient Op9 mesh contains no native draw primitives".to_string()); + } + Ok(primitives) +} + +fn ambient_effect_mesh_primitive_vertices( + frame: &fparkan_fx::fx_visual::EffectMeshFrame, + primitive: AmbientEffectMeshPrimitive, +) -> Vec<VulkanStaticVertex> { + let end = primitive.vertex_start + primitive.vertex_count; + frame.vertices[primitive.vertex_start..end] + .iter() + .zip(&frame.uv[primitive.vertex_start..end]) + .map(|(position, uv)| { + environment_vertex_with_alpha( + *position, + [frame.color[0], frame.color[1], frame.color[2]], + frame.color[3].clamp(0.0, 1.0), + *uv, + ) + }) + .collect() +} + +fn ambient_effect_mesh_primitive_indices( + frame: &fparkan_fx::fx_visual::EffectMeshFrame, + primitive: AmbientEffectMeshPrimitive, +) -> Result<Vec<u32>, String> { + let index_end = primitive.index_start + primitive.index_count; + frame.indices[primitive.index_start..index_end] + .iter() + .map(|index| { + usize::from(*index) + .checked_sub(primitive.vertex_start) + .and_then(|local| u32::try_from(local).ok()) + .ok_or_else(|| { + "ambient Op9 primitive index is outside its vertex block".to_string() + }) + }) + .collect() +} + +fn write_ambient_effect_mesh_primitive( + mesh: &mut VulkanStaticMesh, + range: &EnvironmentGpuRange, + frame: &fparkan_fx::fx_visual::EffectMeshFrame, + primitive: AmbientEffectMeshPrimitive, +) -> Result<(), String> { + if range.vertex_capacity != primitive.vertex_count { + return Err("ambient Op9 primitive vertex allocation changed".to_string()); + } + let vertex_end = range + .vertex_start + .checked_add(range.vertex_capacity) + .ok_or_else(|| "ambient Op9 vertex range overflows".to_string())?; + let destination = mesh + .vertices + .get_mut(range.vertex_start..vertex_end) + .ok_or_else(|| "ambient Op9 vertex allocation is out of bounds".to_string())?; + let draw_range = mesh + .draw_ranges + .get(range.range_index) + .ok_or_else(|| "ambient Op9 draw range is out of bounds".to_string())?; + let index_start = usize::try_from(draw_range.first_index) + .map_err(|_| "ambient Op9 first index exceeds usize".to_string())?; + let index_end = index_start + .checked_add(primitive.index_count) + .ok_or_else(|| "ambient Op9 index range overflows".to_string())?; + let current_indices = mesh + .indices + .get(index_start..index_end) + .ok_or_else(|| "ambient Op9 index allocation is out of bounds".to_string())?; + let frame_indices = frame + .indices + .get(primitive.index_start..primitive.index_start + primitive.index_count) + .ok_or_else(|| "ambient Op9 source primitive index range is out of bounds".to_string())?; + for (current, local) in current_indices.iter().zip(frame_indices) { + let primitive_local = usize::from(*local) + .checked_sub(primitive.vertex_start) + .ok_or_else(|| "ambient Op9 source index leaves its primitive".to_string())?; + let expected = u32::try_from(range.vertex_start) + .ok() + .and_then(|base| base.checked_add(u32::try_from(primitive_local).ok()?)) + .ok_or_else(|| "ambient Op9 primitive index exceeds u32".to_string())?; + if *current != expected { + return Err("ambient Op9 primitive topology changed after GPU preparation".to_string()); + } + } + let frame_vertex_end = primitive + .vertex_start + .checked_add(primitive.vertex_count) + .ok_or_else(|| "ambient Op9 source primitive vertex range overflows".to_string())?; + let frame_vertices = frame + .vertices + .get(primitive.vertex_start..frame_vertex_end) + .ok_or_else(|| "ambient Op9 source primitive vertex range is out of bounds".to_string())?; + let frame_uvs = frame + .uv + .get(primitive.vertex_start..frame_vertex_end) + .ok_or_else(|| "ambient Op9 source primitive UV range is out of bounds".to_string())?; + for ((target, position), uv) in destination.iter_mut().zip(frame_vertices).zip(frame_uvs) { + *target = environment_vertex_with_alpha( + *position, + [frame.color[0], frame.color[1], frame.color[2]], + frame.color[3].clamp(0.0, 1.0), + *uv, + ); + } + Ok(()) +} + +fn ambient_effect_vertex_rgb(command_rgb: [f32; 3], material_additive_rgb: [f32; 3]) -> [f32; 3] { + std::array::from_fn(|channel| command_rgb[channel] * material_additive_rgb[channel]) +} + +fn apply_ambient_effect_material_tints( + slots: &[AmbientEffectGpuSlot], + mesh: &mut VulkanStaticMesh, + updates: &[AmbientEffectGeometryUpdate], + active_materials: &HashMap<usize, ActiveMaterialState>, +) -> Result<(), String> { + for update in updates.iter().filter(|update| update.index_count > 0) { + let range = slots + .iter() + .find_map(|slot| ambient_effect_range_for(slot, update.range_index)) + .ok_or_else(|| "ambient FX draw range has no owning slot".to_string())?; + let material = active_materials + .get(&update.range_index) + .ok_or_else(|| "ambient FX draw range has no active MAT0 phase".to_string())?; + let vertex_rgb = ambient_effect_vertex_rgb(update.command_rgb, material.additive_rgb); + let end = range + .vertex_start + .checked_add(range.vertex_capacity) + .ok_or_else(|| "ambient FX vertex range overflows".to_string())?; + let vertices = mesh + .vertices + .get_mut(range.vertex_start..end) + .ok_or_else(|| "ambient FX vertex allocation is out of bounds".to_string())?; + for vertex in vertices { + vertex.color = vertex_rgb; + } + } + Ok(()) +} + +fn ambient_effect_draw_sort_keys( + mesh: &VulkanStaticMesh, + slots: &[AmbientEffectGpuSlot], + camera_world: [f32; 3], +) -> Result<Vec<(usize, f32)>, String> { + slots.iter().try_fold(Vec::new(), |mut keys, slot| { + for (range, _) in ambient_effect_ranges(slot) { + keys.push(( + range.range_index, + first_indexed_vertex_distance(mesh, range.range_index, camera_world)?, + )); + } + Ok(keys) + }) +} + +fn world_draw_range_indices( + draw_range_count: usize, + fixed_range_indices: &HashSet<usize>, +) -> Vec<usize> { + (0..draw_range_count) + .filter(|range_index| !fixed_range_indices.contains(range_index)) + .collect() +} + +// TEMPORARY diagnostic for ROADMAP.md A.4 (smoke_fr_02 speed). Enabled by +// FPARKAN_DIAG_SMOKE; identifies the emitter by its Op8 body (pool 30, +// interval 0.08) and reports spawn rate, particle lifetime and rise speed +// once per two seconds of effect clock. Remove after the investigation. +#[derive(Default)] +struct SmokeDiagnosticTrack { + window_start: Option<f32>, + last_born: f32, + spawns: usize, + calls: usize, + max_age: f32, + sum_age: f64, + sum_z: f64, + sum_age_z: f64, + sum_age_age: f64, + samples: usize, +} + +fn smoke_fr_02_diagnostic( + slot: &AmbientEffectGpuSlot, + clock: f32, + phase: f32, + frames: &[fparkan_fx::fx_visual::EffectQuadFrame], +) { + static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new(); + static TRACKS: std::sync::OnceLock< + std::sync::Mutex<HashMap<(usize, usize, usize, usize, usize), SmokeDiagnosticTrack>>, + > = std::sync::OnceLock::new(); + if !*ENABLED.get_or_init(|| std::env::var_os("FPARKAN_DIAG_SMOKE").is_some()) { + return; + } + let body = slot.command.raw_body.as_ref(); + let read_u32 = |offset: usize| { + body.get(offset..offset + 4) + .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four bytes"))) + }; + if read_u32(0x20) != Some(30) || read_u32(0x14).map(f32::from_bits) != Some(0.08) { + return; + } + let key = ( + slot.owner_object_index, + slot.owner_component_index, + slot.control_table_index, + slot.control_row_index, + slot.command_index, + ); + let mut tracks = TRACKS + .get_or_init(|| std::sync::Mutex::new(HashMap::new())) + .lock() + .expect("smoke diagnostic lock"); + let slot_count = tracks.len() + usize::from(!tracks.contains_key(&key)); + let track = tracks.entry(key).or_default(); + let window_start = *track.window_start.get_or_insert(clock); + track.calls += 1; + for frame in frames.iter().filter(|frame| frame.active) { + let born = clock - frame.elapsed_seconds; + if born > track.last_born + 1e-4 { + track.spawns += 1; + track.last_born = track.last_born.max(born); + } + let z = frame.vertices.iter().map(|vertex| vertex[2]).sum::<f32>() * 0.25; + let age = f64::from(frame.elapsed_seconds); + track.max_age = track.max_age.max(frame.elapsed_seconds); + track.sum_age += age; + track.sum_z += f64::from(z); + track.sum_age_z += age * f64::from(z); + track.sum_age_age += age * age; + track.samples += 1; + } + let window = clock - window_start; + if window < 2.0 { + return; + } + let n = track.samples as f64; + let rise_per_second = if n > 1.0 { + (n * track.sum_age_z - track.sum_age * track.sum_z) + / (n * track.sum_age_age - track.sum_age * track.sum_age) + } else { + 0.0 + }; + let axis_lengths: [f32; 3] = std::array::from_fn(|column| { + (0..3) + .map(|row| slot.world_from_effect[row][column].powi(2)) + .sum::<f32>() + .sqrt() + }); + eprintln!( + "smoke_diag slots={slot_count} key={key:?} clock={clock:.3} window={window:.3}s calls={} calls_per_s={:.1} spawns={} spawns_per_s={:.2} active_now={} max_age={:.3}s rise_per_s={rise_per_second:.2} rise_per_life_2_4s={:.1} phase={phase:.3} emitter=[{:.2},{:.2},{:.2}] effect_axes={axis_lengths:.3?}", + track.calls, + track.calls as f32 / window, + track.spawns, + track.spawns as f32 / window, + frames.iter().filter(|frame| frame.active).count(), + track.max_age, + rise_per_second * 2.4, + slot.world_from_effect[0][3], + slot.world_from_effect[1][3], + slot.world_from_effect[2][3], + ); + *track = SmokeDiagnosticTrack { + window_start: Some(clock), + last_born: track.last_born, + ..SmokeDiagnosticTrack::default() + }; +} + +// TEMPORARY diagnostic for ROADMAP.md A.4 (animal "aura" speed). Enabled by +// FPARKAN_DIAG_FX_MATERIAL=<material name>, for example smoke_yg. Reports the +// effect-phase rate, call rate, particle lifetime and rise per particle life +// for every Op7/Op8 slot whose command uses that MAT0. Remove after the +// investigation. +#[derive(Default)] +struct FxSpeedDiagnosticTrack { + window_start: Option<f32>, + calls: usize, + phase_travel: f32, + last_phase: Option<f32>, + max_age: f32, + sum_t: f64, + sum_z: f64, + sum_tz: f64, + sum_tt: f64, + samples: usize, +} + +fn fx_material_speed_diagnostic( + slot: &AmbientEffectGpuSlot, + material_name_offset: usize, + clock: f32, + phase: f32, + frames: &[fparkan_fx::fx_visual::EffectQuadFrame], +) { + static WANTED: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new(); + static TRACKS: std::sync::OnceLock< + std::sync::Mutex<HashMap<(usize, usize, usize, usize, usize), FxSpeedDiagnosticTrack>>, + > = std::sync::OnceLock::new(); + let Some(wanted) = WANTED + .get_or_init(|| { + std::env::var("FPARKAN_DIAG_FX_MATERIAL") + .ok() + .map(|name| name.to_ascii_lowercase()) + }) + .as_deref() + else { + return; + }; + let body = slot.command.raw_body.as_ref(); + let Some(name) = body.get(material_name_offset..material_name_offset + 32) else { + return; + }; + let name = name.split(|byte| *byte == 0).next().unwrap_or_default(); + if !name.eq_ignore_ascii_case(wanted.as_bytes()) { + return; + } + let key = ( + slot.owner_object_index, + slot.owner_component_index, + slot.control_table_index, + slot.control_row_index, + slot.command_index, + ); + let mut tracks = TRACKS + .get_or_init(|| std::sync::Mutex::new(HashMap::new())) + .lock() + .expect("fx diagnostic lock"); + let slot_count = tracks.len() + usize::from(!tracks.contains_key(&key)); + let track = tracks.entry(key).or_default(); + let window_start = *track.window_start.get_or_insert(clock); + track.calls += 1; + if let Some(last_phase) = track.last_phase { + let delta = phase - last_phase; + track.phase_travel += if delta < -0.5 { delta + 1.0 } else { delta }; + } + track.last_phase = Some(phase); + for frame in frames.iter().filter(|frame| frame.active) { + // Op8 reports the particle age in seconds; Op7 reports its normalized + // particle age, so the regression is per second or per life. + let t = if slot.command.opcode == FxOpcode::Op8 { + frame.elapsed_seconds + } else { + frame.normalized_time + }; + let z = frame.vertices.iter().map(|vertex| vertex[2]).sum::<f32>() * 0.25; + track.max_age = track.max_age.max(t); + track.sum_t += f64::from(t); + track.sum_z += f64::from(z); + track.sum_tz += f64::from(t) * f64::from(z); + track.sum_tt += f64::from(t) * f64::from(t); + track.samples += 1; + } + let window = clock - window_start; + if window < 3.0 { + return; + } + let n = track.samples as f64; + let slope = if n > 1.0 { + (n * track.sum_tz - track.sum_t * track.sum_z) + / (n * track.sum_tt - track.sum_t * track.sum_t) + } else { + 0.0 + }; + eprintln!( + "fx_diag material={wanted} op={:?} slots={slot_count} key={key:?} clock={clock:.3} window={window:.3}s calls_per_s={:.1} phase_rate_per_s={:.4} active_now={} max_age={:.3} rise_slope={slope:.2} ({}) duration={:.2} mode={} owner=[{:.1},{:.1},{:.1}]", + slot.command.opcode, + track.calls as f32 / window, + track.phase_travel / window, + frames.iter().filter(|frame| frame.active).count(), + track.max_age, + if slot.command.opcode == FxOpcode::Op8 { "units/s, age in s" } else { "units/life, age normalized" }, + slot.duration_seconds, + slot.time_mode, + slot.world_from_effect[0][3], + slot.world_from_effect[1][3], + slot.world_from_effect[2][3], + ); + *track = FxSpeedDiagnosticTrack { + window_start: Some(clock), + last_phase: track.last_phase, + ..FxSpeedDiagnosticTrack::default() + }; +} + +fn ambient_effect_phase( + time_mode: u32, + header_flags: u32, + elapsed_seconds: f32, + duration_seconds: f32, +) -> Option<f32> { + if !elapsed_seconds.is_finite() + || elapsed_seconds < 0.0 + || !duration_seconds.is_finite() + || duration_seconds < 0.0 + { + return None; + } + let mut phase = match time_mode { + 0 => 0.0, + // The native phase stays raw through the optional 0x200/0x20 + // transforms; the command-level 0x8120 path clamps only afterward. + 1 if duration_seconds > 0.0 => elapsed_seconds / duration_seconds, + 2 if duration_seconds > 0.0 => { + elapsed_seconds.rem_euclid(duration_seconds) / duration_seconds + } + _ => return None, + }; + if header_flags & 0x200 != 0 && duration_seconds > 0.0 { + phase *= elapsed_seconds / duration_seconds; + } + if header_flags & 0x20 != 0 { + phase = if phase >= 0.5 { + (1.0 - phase) * 2.0 + } else { + phase * 2.0 + }; + } + Some(phase) +} + fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] { let [x, y, z, w] = rotation; let tx = 2.0 * (y * value[2] - z * value[1]); @@ -4217,163 +7017,890 @@ fn initial_control_gate_values(control: &PreparedComponentControl) -> Vec<Option .collect() } -fn control_has_tag21(control: &PreparedComponentControl) -> bool { - control.typed_records.iter().any(|record| record.tag == 21) +fn control_clock_ms_for_frame(target_ms: u32, frame_index: u64, target_frames: u64) -> u32 { + if target_frames <= 1 { + return target_ms; + } + let last_frame = u128::from(target_frames - 1); + let frame = u128::from(frame_index).min(last_frame); + ((u128::from(target_ms) * frame) / last_frame) as u32 } -fn component_chain_has_tag21( - tree: Option<&[UnitComponentTreeNode]>, - controls: &[Option<PreparedComponentControl>], +fn replay_environment_delta_seconds(last_applied_ms: u32, current_ms: u32) -> f32 { + current_ms.saturating_sub(last_applied_ms) as f32 / 1_000.0 +} + +fn control_clock_mode_label(target_ms: Option<u32>) -> String { + match target_ms { + None => "realtime".to_string(), + Some(0) => "fixed-0ms".to_string(), + Some(target_ms) => format!("ramp-0-to-{target_ms}ms"), + } +} + +fn write_readback_sequence_frame( + directory: &Path, + frame_index: u64, + control_clock_ms: u32, + artifact: &VulkanReadbackArtifact, +) -> Result<PathBuf, String> { + std::fs::create_dir_all(directory).map_err(|error| { + format!( + "create readback sequence directory {}: {error}", + directory.display() + ) + })?; + let path = directory.join(format!( + "frame-{frame_index:06}-clock-{control_clock_ms:010}ms-{}x{}.rgba", + artifact.width, artifact.height, + )); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|error| format!("create readback frame {}: {error}", path.display()))?; + file.write_all(&artifact.bytes) + .map_err(|error| format!("write readback frame {}: {error}", path.display()))?; + Ok(path) +} + +fn quaternion_yaw_z_degrees(rotation: [f32; 4]) -> Option<f32> { + if !rotation.iter().all(|value| value.is_finite()) { + return None; + } + let [x, y, z, w] = rotation; + let radians = (2.0 * (w * z + x * y)).atan2(1.0 - 2.0 * (y * y + z * z)); + radians.is_finite().then_some(radians.to_degrees()) +} + +fn emit_control_animation_capture_diagnostics( + instances: &[Vec<Option<ControlAnimationInstance>>], + pose_buffers: &[Vec<Option<NodePoseBuffer>>], + control_clock_ms: u32, +) { + for (object_index, components) in instances.iter().enumerate() { + for (component_index, instance) in components.iter().enumerate() { + let Some(instance) = instance.as_ref() else { + continue; + }; + let mut gate_index = 0; + for record in instance + .control + .typed_records + .iter() + .filter(|record| record.tag == 21) + { + let mapped_row = record + .values + .first() + .and_then(|row| usize::try_from(*row).ok()); + let timeline = mapped_row.and_then(|row| instance.control.timeline_rows.get(row)); + let node_index = timeline.map(|row| row.node_index); + let sampled_time = node_index + .and_then(|node| instance.last_sampled_node_times.get(node)) + .copied() + .flatten(); + let pose = pose_buffers + .get(object_index) + .and_then(|items| items.get(component_index)) + .and_then(Option::as_ref) + .and_then(|hierarchy| node_index.and_then(|node| hierarchy.poses.get(node))); + let rotation = pose.map(|pose| pose.rotation); + let yaw_z_degrees = rotation.and_then(quaternion_yaw_z_degrees); + let gate_value = instance.gate_values.get(gate_index).copied().flatten(); + eprintln!( + "control_animation_capture object={object_index} component={component_index} clock_ms={control_clock_ms} gate_value={gate_value:?} gate_provider={:?} row={mapped_row:?} node={node_index:?} sampled_time={sampled_time:?} rotation={rotation:?} yaw_z_degrees={yaw_z_degrees:?}", + record.gate_provider, + ); + gate_index += 1; + } + } + } +} + +fn update_control_animation_pose_buffers( + instances: &mut [Vec<Option<ControlAnimationInstance>>], + pose_buffers: &mut [Vec<Option<NodePoseBuffer>>], + native_clock_ms: u32, +) -> Result<(), String> { + for (object_index, components) in instances.iter_mut().enumerate() { + let Some(object_pose_buffers) = pose_buffers.get_mut(object_index) else { + continue; + }; + for (component_index, instance) in components.iter_mut().enumerate() { + let Some(instance) = instance.as_mut() else { + continue; + }; + let node_times: Vec<Option<AnimationTime>> = match sample_control_node_times( + &instance.control, + native_clock_ms, + &instance.gate_values, + &mut instance.state, + ) { + Ok(times) => { + instance.last_sampled_node_times = times.clone(); + times + .into_iter() + .map(|time| time.map(AnimationTime)) + .collect() + } + Err( + error @ (ControlSamplingError::UnsupportedTag21Mode { .. } + | ControlSamplingError::UnsupportedVariableMapping { .. } + | ControlSamplingError::MissingGateProvider { .. } + | ControlSamplingError::GateProviderKeyOutOfRange { .. } + | ControlSamplingError::MissingGateProviderValue { .. } + | ControlSamplingError::GateProviderCountMismatch { .. }), + ) => { + if !instance.reported_unsupported_fallback { + eprintln!( + "control_animation_fallback object={object_index} component={component_index} error={error:?}" + ); + instance.reported_unsupported_fallback = true; + } + let fallback_times = static_preview_node_times( + instance.control.model_node_count, + &instance.control.bindings, + ) + .map_err(|fallback| { + format!( + "control animation fallback for object {object_index} component {component_index}: {fallback}" + ) + })?; + instance.last_sampled_node_times = fallback_times + .iter() + .map(|time| time.map(|time| time.0)) + .collect(); + fallback_times + } + Err(error) => { + return Err(format!( + "control animation sample for object {object_index} component {component_index}: {error:?}" + )); + } + }; + let hierarchy = node38_sampled_hierarchy_at_times(&instance.model, &node_times) + .or_else(|| node38_fallback_hierarchy(&instance.model)); + if let Some(hierarchy) = hierarchy { + if let Some(pose_buffer) = object_pose_buffers.get_mut(component_index) { + *pose_buffer = Some(hierarchy); + } + } + } + } + Ok(()) +} + +fn control_tree_chain_has_tag21( component_index: usize, + tree: Option<&[fparkan_prototype::UnitComponentTreeNode]>, + controls: &[Option<PreparedComponentControl>], ) -> bool { - let Some(tree) = tree else { - return false; - }; let mut current = Some(component_index); - for _ in 0..=tree.len() { - let Some(index) = current else { - return false; - }; + let mut remaining = controls.len(); + while let Some(index) = current { if controls .get(index) .and_then(Option::as_ref) - .is_some_and(control_has_tag21) + .is_some_and(|control| control.typed_records.iter().any(|record| record.tag == 21)) { return true; } - current = tree.get(index).and_then(|node| node.parent_index); + if remaining == 0 { + return false; + } + remaining -= 1; + current = tree + .and_then(|nodes| nodes.get(index)) + .and_then(|node| node.parent_index); } false } -fn refresh_dynamic_component_mounts( - mounts: &[DynamicComponentMount], +fn update_dynamic_component_geometry( + geometry: &[Vec<Option<DynamicComponentGeometry>>], pose_buffers: &[Vec<Option<NodePoseBuffer>>], mount_poses: &mut [Vec<Option<VulkanNodePose>>], -) -> Result<bool, String> { - let mut changed = false; - for mount in mounts { - let parent_mounted = mount_poses - .get(mount.object_index) - .and_then(|poses| poses.get(mount.parent_index)) - .copied() - .flatten(); - let parent_hierarchy = pose_buffers - .get(mount.object_index) - .and_then(|buffers| buffers.get(mount.parent_index)) - .and_then(Option::as_ref) - .ok_or_else(|| { - format!( - "dynamic component {} parent {} has no sampled Node38 hierarchy", - mount.component_index, mount.parent_index - ) + mesh: &mut VulkanStaticMesh, +) -> Result<(), String> { + let identity_pose = VulkanNodePose { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, 1.0], + }; + for (object_index, components) in geometry.iter().enumerate() { + for (component_index, component) in components.iter().enumerate() { + let Some(component) = component else { + continue; + }; + if let Some((parent_index, socket)) = component.parent_mount { + let parent_mounted = mount_poses + .get(object_index) + .and_then(|items| items.get(parent_index)) + .ok_or_else(|| { + format!("dynamic component {object_index}:{component_index} has no parent mount slot") + })? + .is_some(); + let parent_hierarchy = pose_buffers + .get(object_index) + .and_then(|items| items.get(parent_index)) + .and_then(Option::as_ref) + .ok_or_else(|| { + format!( + "dynamic component {object_index}:{component_index} has no parent pose" + ) + })?; + let parent_socket = if parent_mounted { + node38_pose_relative_to_root_from_hierarchy(parent_hierarchy, socket) + } else { + node38_pose_from_hierarchy(parent_hierarchy, socket) + } + .ok_or_else(|| { + format!("dynamic component {object_index}:{component_index} parent socket {socket} is unavailable") + })?; + let parent_root = mount_poses + .get(object_index) + .and_then(|items| items.get(parent_index)) + .copied() + .flatten() + .unwrap_or(identity_pose); + let mount = mount_poses + .get_mut(object_index) + .and_then(|items| items.get_mut(component_index)) + .ok_or_else(|| { + format!( + "dynamic component {object_index}:{component_index} has no mount slot" + ) + })?; + *mount = Some(compose_unit_pose(parent_root, parent_socket)); + } + let hierarchy = pose_buffers + .get(object_index) + .and_then(|items| items.get(component_index)) + .and_then(Option::as_ref) + .ok_or_else(|| { + format!( + "dynamic component {object_index}:{component_index} has no sampled pose" + ) + })?; + let mount = mount_poses + .get(object_index) + .and_then(|items| items.get(component_index)) + .copied() + .flatten(); + let projected = match mount { + Some(mount) => { + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( + &component.model, + component.root_transform, + component.owner_scale, + hierarchy, + mount, + ) + } + None => project_msh_to_static_mesh_in_world_space_with_node_pose_buffer( + &component.model, + component.root_transform, + component.owner_scale, + hierarchy, + ), + } + .map_err(|error| { + format!("reproject dynamic component {object_index}:{component_index}: {error}") })?; - let socket_pose = if parent_mounted.is_some() { - node38_pose_relative_to_root_from_hierarchy(parent_hierarchy, mount.socket) - } else { - node38_pose_from_hierarchy(parent_hierarchy, mount.socket) + if projected.vertices.len() != component.vertex_count { + return Err(format!( + "dynamic component {object_index}:{component_index} changed vertex count from {} to {}", + component.vertex_count, + projected.vertices.len() + )); + } + let end = component + .vertex_start + .checked_add(component.vertex_count) + .filter(|end| *end <= mesh.vertices.len()) + .ok_or_else(|| { + format!( + "dynamic component {object_index}:{component_index} vertex span is invalid" + ) + })?; + mesh.vertices[component.vertex_start..end].copy_from_slice(&projected.vertices); } - .ok_or_else(|| { - format!( - "dynamic component {} mount socket {} is absent from parent {}", - mount.component_index, mount.socket, mount.parent_index - ) - })?; - let root_pose = parent_mounted.unwrap_or(VulkanNodePose { - translation: [0.0; 3], - rotation: [0.0, 0.0, 0.0, 1.0], - }); - let next_pose = compose_unit_pose(root_pose, socket_pose); - let slot = mount_poses - .get_mut(mount.object_index) - .and_then(|poses| poses.get_mut(mount.component_index)) - .ok_or_else(|| { - format!( - "dynamic component {} mount slot is outside object {}", - mount.component_index, mount.object_index - ) - })?; - if *slot != Some(next_pose) { - *slot = Some(next_pose); - changed = true; + } + Ok(()) +} + +struct MissionBasementFootprint { + object_index: usize, + outer: Vec<[f32; 2]>, + inner: Vec<[f32; 3]>, + flatten_inner_to_mean_outer_z: bool, +} + +fn apply_basement_placement_deltas( + roots: &[MissionObjectDraft], + footprints: &[MissionBasementFootprint], + placements: &[BasementPlacement], +) -> Result<Vec<MissionObjectDraft>, String> { + if footprints.len() != placements.len() { + return Err("basement placement results are not aligned with mission roots".to_string()); + } + let mut adjusted_roots = roots.to_vec(); + for (footprint, placement) in footprints.iter().zip(placements) { + let root = adjusted_roots + .get_mut(footprint.object_index) + .ok_or_else(|| "basement footprint references an unknown mission root".to_string())?; + root.position = root.root_position_with_basement_delta(placement.placement_delta); + } + Ok(adjusted_roots) +} + +fn mission_basement_footprints( + assets: &MissionAssets, + roots: &[MissionObjectDraft], +) -> Result<Vec<MissionBasementFootprint>, String> { + let mut footprints = Vec::new(); + for (object_index, mission_root) in roots.iter().enumerate() { + let Some(mode) = mission_root.basement_placement_mode() else { + continue; + }; + let Some(component_bas) = assets + .object_component_bas + .get(object_index) + .and_then(|components| components.first()) + .and_then(Option::as_ref) + else { + continue; + }; + let geometry = &component_bas.geometry; + let bas_label = format!( + "{}:{}", + component_bas.source.archive.as_str(), + String::from_utf8_lossy(&component_bas.source.name.0) + ); + // shortcut: the installed Iron Strategy corpus has one inner and one + // outer ring per eligible DAT. Native iterates both arrays, so reject + // unsupported multiplicity until the multi-ring semantics are ported. + if geometry.first.len() != 1 || geometry.second.len() != 1 { + return Err(format!( + "eligible mission object {object_index} from {bas_label} has {} inner and {} outer BAS rings; one of each is supported", + geometry.first.len(), + geometry.second.len() + )); } + let transform = LegacyIron3dEulerTransform { + translation: mission_root.position, + orientation_radians: mission_root.orientation_raw, + }; + let transform_points = |points: &[[f32; 3]], ring_name: &str| { + let mut transformed_points = Vec::with_capacity(points.len()); + for &point in points { + let transformed = transform + .try_transform_scaled_point(point, mission_root.scale) + .ok_or_else(|| { + format!( + "building BAS {ring_name} ring on mission object {object_index} from {bas_label} has a non-finite transformed point" + ) + })?; + transformed_points.push(transformed); + } + Ok::<_, String>(transformed_points) + }; + let inner_record = &geometry.first[0]; + let outer_record = &geometry.second[0]; + for (ring_name, declared_count, points) in [ + ("inner", inner_record.declared_count, &inner_record.points), + ("outer", outer_record.declared_count, &outer_record.points), + ] { + let expected_point_count = usize::try_from(declared_count) + .ok() + .and_then(|count| count.checked_add(1)) + .ok_or_else(|| format!("building BAS {ring_name} count overflows: {bas_label}"))?; + if usize::try_from(declared_count).unwrap_or(0) < 3 + || points.len() != expected_point_count + { + return Err(format!( + "building BAS {ring_name} ring on mission object {object_index} from {bas_label} has count {declared_count} and {} points", + points.len() + )); + } + } + let inner = transform_points(&inner_record.points, "inner")?; + let outer_3d = transform_points(&outer_record.points, "outer")?; + footprints.push(MissionBasementFootprint { + object_index, + outer: outer_3d.iter().map(|point| [point[0], point[1]]).collect(), + inner, + flatten_inner_to_mean_outer_z: mode == BasementPlacementMode::FlattenInnerAndAdjustRoot, + }); } - Ok(changed) + Ok(footprints) } -fn static_preview_control_pose_buffer( - model: &ModelAsset, - control: &PreparedComponentControl, - native_clock_ms: u32, - state: &mut ControlInstanceState, -) -> Result<Option<NodePoseBuffer>, String> { - if control.model_node_count != model.node_count { +fn interpolate_static_terrain_vertex( + position: [f32; 3], + source: [VulkanStaticVertex; 3], +) -> Result<VulkanStaticVertex, String> { + let point = [f64::from(position[0]), f64::from(position[1])]; + let a = [ + f64::from(source[0].position[0]), + f64::from(source[0].position[1]), + ]; + let b = [ + f64::from(source[1].position[0]), + f64::from(source[1].position[1]), + ]; + let c = [ + f64::from(source[2].position[0]), + f64::from(source[2].position[1]), + ]; + let denominator = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + if !denominator.is_finite() || denominator.abs() <= 1.0e-10 { + return Err("terrain source triangle has no stable XY barycentric coordinates".to_string()); + } + let second_weight = + ((point[0] - a[0]) * (c[1] - a[1]) - (point[1] - a[1]) * (c[0] - a[0])) / denominator; + let third_weight = + ((b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (point[0] - a[0])) / denominator; + let weights = [ + 1.0 - second_weight - third_weight, + second_weight, + third_weight, + ]; + if !weights.iter().all(|weight| weight.is_finite()) { + return Err("terrain split vertex has invalid barycentric coordinates".to_string()); + } + let blend2 = |values: [[f32; 2]; 3]| { + [ + (0..3) + .map(|index| values[index][0] * weights[index] as f32) + .sum(), + (0..3) + .map(|index| values[index][1] * weights[index] as f32) + .sum(), + ] + }; + let blend3 = |values: [[f32; 3]; 3]| { + std::array::from_fn(|axis| { + (0..3) + .map(|index| values[index][axis] * weights[index] as f32) + .sum() + }) + }; + let overlay_alpha = (0..3) + .map(|index| source[index].overlay_alpha * weights[index] as f32) + .sum::<f32>(); + if !overlay_alpha.is_finite() { return Err(format!( - "control model node count {} does not match MSH node count {}", - control.model_node_count, model.node_count + "terrain split vertex overlay alpha is non-finite: position={position:?}, weights={weights:?}, source_position={:?}, source_alpha={:?}, result={overlay_alpha}", + source.map(|vertex| vertex.position), + source.map(|vertex| vertex.overlay_alpha) )); } - let gate_values = initial_control_gate_values(control); - let sampled_times = match sample_control_node_times( - control, - native_clock_ms, - &gate_values, - state, - ) { - Ok(times) => times, - Err( - error @ (ControlSamplingError::UnsupportedTag21Mode { .. } - | ControlSamplingError::UnsupportedVariableMapping { .. } - | ControlSamplingError::MissingGateProvider { .. } - | ControlSamplingError::GateProviderKeyOutOfRange { .. } - | ControlSamplingError::MissingGateProviderValue { .. } - | ControlSamplingError::GateProviderCountMismatch { .. }), - ) => { - return static_preview_component_pose_buffer(model, &control.bindings, None) - .map_err(|fallback| { - format!( - "control timeline sample failed ({error:?}) and static CTLD fallback failed: {fallback}" - ) - }); + const OVERLAY_ALPHA_ROUNDING_TOLERANCE: f32 = 1.0e-5; + if overlay_alpha < -OVERLAY_ALPHA_ROUNDING_TOLERANCE + || overlay_alpha > 1.0 + OVERLAY_ALPHA_ROUNDING_TOLERANCE + { + return Err(format!( + "terrain split vertex overlay alpha exceeds barycentric rounding tolerance: position={position:?}, weights={weights:?}, source_position={:?}, source_alpha={:?}, result={overlay_alpha}", + source.map(|vertex| vertex.position), + source.map(|vertex| vertex.overlay_alpha) + )); + } + let vertex = VulkanStaticVertex { + position, + color: blend3(source.map(|vertex| vertex.color)), + normal: blend3(source.map(|vertex| vertex.normal)), + uv: blend2(source.map(|vertex| vertex.uv)), + detail_uv: blend2(source.map(|vertex| vertex.detail_uv)), + overlay_alpha: overlay_alpha.clamp(0.0, 1.0), + }; + if !vertex + .position + .iter() + .chain(vertex.color.iter()) + .chain(vertex.normal.iter()) + .chain(vertex.uv.iter()) + .chain(vertex.detail_uv.iter()) + .chain(std::iter::once(&vertex.overlay_alpha)) + .all(|value| value.is_finite()) + { + return Err( + "terrain split vertex contains a non-finite interpolated attribute".to_string(), + ); + } + Ok(vertex) +} + +fn project_basement_foundation( + terrain: &fparkan_terrain_format::LandMeshDocument, + source_mesh: &VulkanStaticMesh, + foundation: &mut BasementFoundation, +) -> Result<(VulkanStaticMesh, Vec<ShadowTriangle>), String> { + let pipeline_state = source_mesh + .draw_ranges + .first() + .map(|range| range.pipeline_state) + .ok_or_else(|| "terrain source mesh has no LOD0 draw ranges".to_string())?; + let (normals, uv0_words) = canonicalize_basement_band_vertices(foundation)?; + let mut mesh = VulkanStaticMesh { + vertices: Vec::new(), + indices: Vec::new(), + draw_ranges: Vec::new(), + }; + let mut shadow_receivers = Vec::with_capacity(foundation.fragments.len()); + + for (fragment_index, fragment) in foundation.fragments.iter().enumerate() { + let (material_index, base_only, vertices) = match fragment.source_face { + Some(face_index) => { + let face = terrain.faces.get(face_index).ok_or_else(|| { + format!("foundation fragment references unknown terrain face {face_index}") + })?; + if face.flags != fragment.surface_mask { + return Err(format!( + "foundation fragment for terrain face {face_index} changed its surface mask" + )); + } + let source_vertices: [VulkanStaticVertex; 3] = face + .vertices + .map(|vertex| { + source_mesh + .vertices + .get(usize::from(vertex)) + .copied() + .ok_or_else(|| { + format!( + "terrain face {face_index} references an unknown projected vertex" + ) + }) + }) + .into_iter() + .collect::<Result<Vec<_>, _>>()? + .try_into() + .map_err(|_| "terrain face did not have three vertices".to_string())?; + let vertices = fragment + .positions + .map(|position| { + source_vertices + .iter() + .find(|vertex| vertex.position == position) + .copied() + .map(Ok) + .unwrap_or_else(|| { + interpolate_static_terrain_vertex(position, source_vertices) + }) + }) + .into_iter() + .collect::<Result<Vec<_>, _>>()? + .try_into() + .map_err(|_| { + "foundation source fragment did not have three vertices".to_string() + })?; + ( + face.material_tag, + fragment.surface_mask.0 & 0x400 == 0, + vertices, + ) + } + None => { + let fragment_normals = normals + .get(fragment_index) + .and_then(Option::as_ref) + .ok_or_else(|| "basement band face has no native vertex normals".to_string())?; + let fragment_uv0_words = uv0_words + .get(fragment_index) + .and_then(Option::as_ref) + .ok_or_else(|| "basement band face lost its original UV words".to_string())?; + let vertices = basement_band_vertices( + fragment.positions, + fragment_uv0_words, + fragment_normals, + ); + (0xff00, true, vertices) + } + }; + + let first_index = u32::try_from(mesh.indices.len()) + .map_err(|_| "basement terrain index buffer exceeds u32".to_string())?; + for vertex in vertices { + let index = u32::try_from(mesh.vertices.len()) + .map_err(|_| "basement terrain vertex buffer exceeds u32".to_string())?; + mesh.vertices.push(vertex); + mesh.indices.push(index); } - Err(error) => { - return Err(format!("control timeline sample failed: {error:?}")); + if let Some(range) = mesh.draw_ranges.last_mut().filter(|range| { + range.material_index == material_index && range.terrain_base_only == base_only + }) { + range.index_count = range + .index_count + .checked_add(3) + .ok_or_else(|| "basement terrain draw range exceeds u32".to_string())?; + } else { + mesh.draw_ranges.push(VulkanStaticDrawRange { + first_index, + index_count: 3, + material_index, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state, + terrain_base_only: base_only, + alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, + }); } - }; - let node_times = sampled_times - .into_iter() - .map(|time| time.map(AnimationTime)) - .collect::<Vec<_>>(); - Ok(node38_sampled_hierarchy_at_times(model, &node_times) - .or_else(|| node38_fallback_hierarchy(model))) + shadow_receivers.push(shadow_triangle(fragment.positions, fragment.surface_mask.0)); + } + + Ok((mesh, shadow_receivers)) } -fn project_dynamic_component_geometry( - dynamic: &DynamicComponentGeometry, - pose: &NodePoseBuffer, - mount: Option<VulkanNodePose>, -) -> Result<VulkanStaticMesh, String> { - let projected = if let Some(mount) = mount { - project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( - &dynamic.model, - dynamic.transform, - dynamic.scale, - pose, - mount, - ) - } else { - project_msh_to_static_mesh_in_world_space_with_node_pose_buffer( - &dynamic.model, - dynamic.transform, - dynamic.scale, - pose, - ) +fn prepare_static_terrain_geometry( + terrain: &fparkan_terrain_format::LandMeshDocument, + source_mesh: VulkanStaticMesh, + terrain_world: &TerrainWorld, + foundation: Option<BasementFoundation>, +) -> Result<(VulkanStaticMesh, Vec<ShadowTriangle>, TerrainWorld), String> { + let Some(mut foundation) = foundation else { + return Ok(( + source_mesh, + terrain_shadow_receivers(terrain)?, + terrain_world.clone(), + )); }; - projected.map_err(|error| { - format!( - "update mission object {} component {} geometry: {error}", - dynamic.object_index, dynamic.component_index - ) + let (mesh, shadow_receivers) = + project_basement_foundation(terrain, &source_mesh, &mut foundation)?; + let terrain_query = terrain_world + .clone() + .with_basement_foundation(foundation) + .map_err(|error| format!("install canonical terrain query surfaces: {error}"))?; + Ok((mesh, shadow_receivers, terrain_query)) +} + +#[derive(Clone, Copy, Debug)] +struct BasementBandVertex { + position: [f32; 3], + uv0: [i16; 2], + // The native callback leaves UV1 unwritten; zero is a deterministic + // assumption for matching generated vertices and is not native parity. + detail_uv: [i16; 2], + alpha: f32, +} + +#[derive(Clone, Copy, Debug)] +struct BasementBandVertexGroup { + position: [f32; 3], + uv0: [i16; 2], + detail_uv: [i16; 2], + alpha: f32, + face_normal_sum: [i64; 3], +} + +fn canonicalize_basement_band_vertices( + foundation: &mut BasementFoundation, +) -> Result<(Vec<Option<[[f32; 3]; 3]>>, Vec<Option<[[i16; 2]; 3]>>), String> { + let mut group_ids_by_fragment = vec![None; foundation.fragments.len()]; + let mut uv0_words_by_fragment = vec![None; foundation.fragments.len()]; + let mut groups = Vec::<BasementBandVertexGroup>::new(); + let mut buckets = HashMap::<(i32, i32, i16, i16), Vec<usize>>::new(); + + // shortcut: installed-corpus probing found no exact UV0 weld key between + // generated bands and clipped source fragments. Extend the index to source + // vertices if a future corpus reaches that key. + for (fragment_index, fragment) in foundation.fragments.iter_mut().enumerate() { + if fragment.source_face.is_some() { + continue; + } + // Preserve the input UV words: canonicalizing a welded position can + // otherwise change this triangle's per-face UV origin. + let vertices = basement_band_uv_vertices(fragment.positions)?; + let mut group_ids = [0_usize; 3]; + let mut uv0_words = [[0_i16; 2]; 3]; + for (corner, vertex) in vertices.into_iter().enumerate() { + uv0_words[corner] = vertex.uv0; + let cell = basement_vertex_cell(vertex.position)?; + let mut candidates = Vec::new(); + for dx in -1..=1 { + for dy in -1..=1 { + let (Some(x), Some(y)) = (cell.0.checked_add(dx), cell.1.checked_add(dy)) + else { + continue; + }; + if let Some(bucket) = buckets.get(&(x, y, vertex.uv0[0], vertex.uv0[1])) { + candidates.extend(bucket.iter().copied()); + } + } + } + candidates.sort_unstable(); + let group_index = candidates + .into_iter() + .find(|&index| { + let existing = groups[index]; + existing.uv0 == vertex.uv0 + && existing.detail_uv == vertex.detail_uv + && existing.alpha.to_bits() == vertex.alpha.to_bits() + && basement_vertex_xy_distance(existing.position, vertex.position) < 0.01 + }) + .unwrap_or_else(|| { + let index = groups.len(); + groups.push(BasementBandVertexGroup { + position: vertex.position, + uv0: vertex.uv0, + detail_uv: vertex.detail_uv, + alpha: vertex.alpha, + face_normal_sum: [0; 3], + }); + buckets + .entry((cell.0, cell.1, vertex.uv0[0], vertex.uv0[1])) + .or_default() + .push(index); + index + }); + fragment.positions[corner] = groups[group_index].position; + group_ids[corner] = group_index; + } + group_ids_by_fragment[fragment_index] = Some(group_ids); + uv0_words_by_fragment[fragment_index] = Some(uv0_words); + } + + for (fragment_index, fragment) in foundation.fragments.iter().enumerate() { + if fragment.source_face.is_some() { + continue; + } + let Some(face_normal) = packed_basement_face_normal(fragment.positions)? else { + continue; + }; + let group_ids = group_ids_by_fragment[fragment_index] + .ok_or_else(|| "basement band face has no welded vertex groups".to_string())?; + // The native normal postpass visits each indexed face once per vertex, + // even if malformed geometry gives two corners the same vertex index. + for group_index in group_ids.into_iter().collect::<HashSet<_>>() { + for axis in 0..3 { + groups[group_index].face_normal_sum[axis] += i64::from(face_normal[axis]); + } + } + } + + let normalized = groups + .iter() + .map(|group| normalized_native_vertex_normal(group.face_normal_sum)) + .collect::<Result<Vec<_>, _>>()?; + Ok(( + group_ids_by_fragment + .into_iter() + .map(|group_ids| group_ids.map(|ids| ids.map(|group| normalized[group]))) + .collect(), + uv0_words_by_fragment, + )) +} + +fn basement_vertex_cell(position: [f32; 3]) -> Result<(i32, i32), String> { + let x = (position[0] / 0.01).floor(); + let y = (position[1] / 0.01).floor(); + if !x.is_finite() + || !y.is_finite() + || x < i32::MIN as f32 + || x > i32::MAX as f32 + || y < i32::MIN as f32 + || y > i32::MAX as f32 + { + return Err("basement band vertex is outside the native weld grid".to_string()); + } + Ok((x as i32, y as i32)) +} + +fn basement_vertex_xy_distance(left: [f32; 3], right: [f32; 3]) -> f32 { + ((left[0] - right[0]).powi(2) + (left[1] - right[1]).powi(2)).sqrt() +} + +fn basement_band_uv_vertices(positions: [[f32; 3]; 3]) -> Result<[BasementBandVertex; 3], String> { + let raw_uv = positions.map(|point| [point[0] * 0.066_f32, point[1] * 0.066_f32]); + if !raw_uv.iter().flatten().all(|value| value.is_finite()) { + return Err("basement band UV is non-finite".to_string()); + } + let origin = std::array::from_fn::<_, 2, _>(|axis| { + raw_uv + .iter() + .map(|uv| uv[axis]) + .fold(f32::INFINITY, f32::min) + .floor() + }); + let mut result = [BasementBandVertex { + position: [0.0; 3], + uv0: [0; 2], + detail_uv: [0; 2], + alpha: 1.0, + }; 3]; + for index in 0..3 { + let mut uv0 = [0_i16; 2]; + for axis in 0..2 { + let scaled = ((raw_uv[index][axis] - origin[axis]) * 1024.0_f32).round_ties_even(); + if !scaled.is_finite() || scaled < i64::MIN as f32 || scaled > i64::MAX as f32 { + return Err("basement band UV exceeds the native conversion range".to_string()); + } + // Native FISTP stores the low signed 16-bit word from its integer result. + uv0[axis] = (scaled as i64) as i16; + } + result[index] = BasementBandVertex { + position: positions[index], + uv0, + detail_uv: [0; 2], + alpha: 1.0, + }; + } + Ok(result) +} + +fn packed_basement_face_normal(positions: [[f32; 3]; 3]) -> Result<Option<[i16; 3]>, String> { + let [a, b, c] = positions.map(|point| point.map(f64::from)); + let first = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let second = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + let cross = [ + first[1] * second[2] - first[2] * second[1], + first[2] * second[0] - first[0] * second[2], + first[0] * second[1] - first[1] * second[0], + ]; + let length = cross.iter().map(|value| value * value).sum::<f64>().sqrt(); + if !length.is_finite() { + return Err("basement band face normal is non-finite".to_string()); + } + if length == 0.0 { + return Ok(None); + } + Ok(Some(cross.map(|value| { + ((value / length * 32767.0).round_ties_even()) as i16 + }))) +} + +fn normalized_native_vertex_normal(face_normal_sum: [i64; 3]) -> Result<[f32; 3], String> { + let normal = face_normal_sum.map(|value| value as f64); + let length = normal.iter().map(|value| value * value).sum::<f64>().sqrt(); + if !length.is_finite() { + return Err("basement band vertex has a non-finite native face-normal sum".to_string()); + } + if length == 0.0 { + return Ok([0.0; 3]); + } + Ok(normal.map(|value| { + let packed = (value / length * 127.0) + .round_ties_even() + .clamp(-127.0, 127.0) as i8; + f32::from(packed) / 128.0 + })) +} + +fn basement_band_vertices( + positions: [[f32; 3]; 3], + uv0_words: &[[i16; 2]; 3], + normals: &[[f32; 3]; 3], +) -> [VulkanStaticVertex; 3] { + std::array::from_fn(|index| VulkanStaticVertex { + position: positions[index], + color: [1.0; 3], + normal: normals[index], + uv: uv0_words[index].map(|value| f32::from(value) / 1024.0), + // The native callback leaves the secondary UV words unwritten. The + // renderer uses zero here as a deterministic assumption; generated + // faces take the base-only path, so the detail coordinate is not sampled. + detail_uv: [0.0; 2], + overlay_alpha: 1.0, }) } @@ -4396,21 +7923,58 @@ fn static_preview_mesh_and_materials( draw_ranges: Vec::new(), }; let mut native_handedness_by_range = HashMap::new(); + let mut type16_present_ranges = HashSet::new(); let mut materials = Vec::new(); let mut material_animations = Vec::new(); - let mut sun_occlusion_range_indices = Vec::new(); - let mut shadow_casters = Vec::new(); - let mut shadow_sphere_baselines = Vec::new(); - let mut shadow_caster_indices_by_object = Vec::with_capacity(roots.len()); + let mut ambient_effect_slots = Vec::new(); + let mut control_point_light_slots = Vec::new(); + let mut effect_documents = HashMap::new(); + let mut effect_materials = HashMap::new(); let mut component_pose_buffers_by_object = Vec::with_capacity(roots.len()); let mut component_mount_poses_by_object = Vec::with_capacity(roots.len()); - let mut control_animation_instances = Vec::new(); - let mut dynamic_component_geometry = Vec::new(); - let mut dynamic_component_mounts = Vec::new(); - let shadow_receivers = terrain_shadow_receivers(terrain_mesh)?; + let mut dynamic_component_geometry_by_object = Vec::with_capacity(roots.len()); + let mut building_collision_bindings = Vec::new(); + let mut collision_models = + HashMap::<fparkan_assets::AssetId<PreparedModel>, Arc<ModelAsset>>::new(); + let mut control_animation_instances_by_object = Vec::with_capacity(roots.len()); + let mut dynamic_shadow_casters = Vec::new(); + let mut sun_occlusion_range_indices = Vec::new(); + let mut shadow_casters = Vec::new(); let mut terrain_material_loader = StandaloneWearMaterialLoader::new(root); - let terrain_component = project_land_msh_to_static_mesh_in_world_space(terrain_mesh) + let source_terrain_component = project_land_msh_to_static_mesh_in_world_space(terrain_mesh) .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?; + let footprint_sources = mission_basement_footprints(assets, roots)?; + let foundation = if footprint_sources.is_empty() { + None + } else { + let footprints = footprint_sources + .iter() + .map(|footprint| BasementFootprint { + outer: &footprint.outer, + inner: &footprint.inner, + flatten_inner_to_mean_outer_z: footprint.flatten_inner_to_mean_outer_z, + }) + .collect::<Vec<_>>(); + Some( + build_basement_foundations(terrain_mesh, &footprints) + .map_err(|error| format!("prepare mission basement foundations: {error}"))?, + ) + }; + let effective_roots = foundation + .as_ref() + .map(|foundation| { + apply_basement_placement_deltas(roots, &footprint_sources, &foundation.placements) + }) + .transpose()?; + // Every visual, mounted component, effect, and shadow caster below uses this + // same root transform, so the native delta is applied once to the hierarchy. + let roots = effective_roots.as_deref().unwrap_or(roots); + let (terrain_component, shadow_receivers, terrain_query) = prepare_static_terrain_geometry( + terrain_mesh, + source_terrain_component, + terrain, + foundation, + )?; let terrain_materials = static_preview_terrain_base_materials( &mut terrain_material_loader, root, @@ -4475,7 +8039,6 @@ fn static_preview_mesh_and_materials( component_controls.map_or(0, Vec::len) )); } - let component_controls = component_controls.map_or(&[][..], Vec::as_slice); let root_native_kind = component_refs .first() .and_then(|(visual, _)| native_shadow_kind(visual.source_type)); @@ -4486,56 +8049,74 @@ fn static_preview_mesh_and_materials( ); let mut root_shadow_triangles = Vec::new(); let mut root_shadow_spheres = Vec::new(); + let mut root_shadow_sphere_sources = Vec::new(); + let mut root_shadow_draw_range_indices = Vec::new(); let mut component_mount_poses = vec![None; component_refs.len()]; let mut component_pose_buffers = vec![None; component_refs.len()]; + let mut dynamic_component_geometry = vec![None; component_refs.len()]; + let mut control_animation_instances = vec![None; component_refs.len()]; for (component_index, (visual, model)) in component_refs.iter().enumerate() { + let should_render = tree.as_ref().map_or(true, |_| { + unit_component_is_visible(component_index, visual.source_type) + }); + let control = component_controls + .and_then(|controls| controls.get(component_index)) + .and_then(Option::as_ref); let Some(model) = *model else { + if legacy_camera.is_none() && root_native_kind == Some(3) && should_render { + return Err(format!( + "building collision object {object_index} component {component_index} has no model" + )); + } + if component_index == 0 { + if let Some(control) = control { + append_initial_control_effect_slots( + root, + mission_root, + object_index, + component_index, + control, + None, + None, + &mut mesh, + &mut materials, + &mut material_animations, + &mut effect_documents, + &mut effect_materials, + &mut ambient_effect_slots, + &mut control_point_light_slots, + )?; + } + } continue; }; - let component_control = component_controls - .get(component_index) - .and_then(Option::as_ref); - let control_animates_component = component_control.is_some_and(control_has_tag21); + let component_bindings = component_controls + .and_then(|controls| controls.get(component_index)) + .and_then(Option::as_ref) + .map_or(&[][..], |control| control.bindings.as_slice()); + if static_animation_frame.is_none() { + if let Some(control) = control { + control_animation_instances[component_index] = Some(ControlAnimationInstance { + control: control.clone(), + model: model.validated.clone(), + gate_values: initial_control_gate_values(control), + state: ControlInstanceState::default(), + last_sampled_node_times: Vec::new(), + reported_unsupported_fallback: false, + }); + } + } + component_pose_buffers[component_index] = static_preview_component_pose_buffer( + &model.validated, + component_bindings, + static_animation_frame, + )?; let timeline_moves_component = static_animation_frame.is_none() - && (control_animates_component - || component_chain_has_tag21( - tree.as_deref(), - component_controls, - component_index, - )); - let runtime_model = timeline_moves_component.then(|| Arc::new(model.validated.clone())); - let mut control_state = ControlInstanceState::default(); - component_pose_buffers[component_index] = - match (static_animation_frame, component_control) { - (None, Some(control)) => static_preview_control_pose_buffer( - &model.validated, - control, - 0, - &mut control_state, - )?, - (animation_frame, control) => static_preview_component_pose_buffer( - &model.validated, - control.map_or(&[][..], |control| control.bindings.as_slice()), - animation_frame, - )?, - }; - if control_animates_component && static_animation_frame.is_none() { - control_animation_instances.push(ControlAnimationInstance { - object_index, + && control_tree_chain_has_tag21( component_index, - model: runtime_model - .as_ref() - .map(Arc::clone) - .ok_or_else(|| "animated component model was not retained".to_string())?, - control: component_control - .ok_or_else(|| "tag-21 component control disappeared".to_string())? - .clone(), - state: control_state, - }); - } - let should_render = tree.as_ref().map_or(true, |_| { - unit_component_is_visible(component_index, visual.source_type) - }); + tree.as_deref(), + component_controls.map_or(&[][..], |controls| controls.as_slice()), + ); // Native AniMesh only resolves global MSH node sockets for EXTO // geometry. Internal INTO components use controller/equipment // slots, whose `parent_or_link` values are not MSH node indices. @@ -4627,36 +8208,52 @@ fn static_preview_mesh_and_materials( if let Some(mount_pose) = mount_pose { component_mount_poses[component_index] = Some(mount_pose); } - if timeline_moves_component && mount_pose.is_some() { - let parent_index = tree - .as_ref() - .and_then(|tree| tree[component_index].parent_index) - .ok_or_else(|| { - format!("mounted dynamic component {component_index} has no parent") - })?; - let socket = usize::try_from( - records - .get(component_index) - .ok_or_else(|| { - format!("unit component {component_index} is outside decoded records") - })? - .parent_or_link, - ) - .map_err(|_| { - format!("unit component {component_index} has a negative mount socket") - })?; - dynamic_component_mounts.push(DynamicComponentMount { - object_index, - component_index, - parent_index, - socket, - }); + // External tree components have a recovered mount pose; flat + // mission components and the root use the mission pose. Hidden + // INTO slots use controller links rather than MSH sockets, so + // their Control owner transform remains unresolved here. + let owner_pose_available = + component_index == 0 || tree.is_none() || mount_pose.is_some(); + if owner_pose_available { + if let Some(control) = control { + append_initial_control_effect_slots( + root, + mission_root, + object_index, + component_index, + control, + component_pose_buffers[component_index].as_ref(), + mount_pose, + &mut mesh, + &mut materials, + &mut material_animations, + &mut effect_documents, + &mut effect_materials, + &mut ambient_effect_slots, + &mut control_point_light_slots, + )?; + } } let transform = LegacyIron3dEulerTransform { translation: mission_root.position, orientation_radians: mission_root.orientation_raw, }; + if legacy_camera.is_none() && root_native_kind == Some(3) && should_render { + let shared_model = collision_models + .entry(model.id) + .or_insert_with(|| Arc::new(model.validated.clone())) + .clone(); + building_collision_bindings.push(building_collision_binding( + object_index, + component_index, + format!("{:?}", model.source), + shared_model, + transform, + mission_root.scale, + mount_pose.is_some(), + )?); + } if should_render { let pose_buffer = component_pose_buffers[component_index].as_ref(); let mut component = match (pose_buffer, mount_pose, static_animation_frame) { @@ -4717,11 +8314,6 @@ fn static_preview_mesh_and_materials( native_msh_cull_mode(range.batch_flags, component_handedness); } let component_shadow_triangles = static_mesh_shadow_triangles(&component); - let shadow_triangle_range = root_native_kind.map(|_| { - let start = root_shadow_triangles.len(); - let end = start.saturating_add(component_shadow_triangles.len()); - start..end - }); if root_native_kind.is_some() { root_shadow_triangles.extend(component_shadow_triangles); let node1_pose = mount_pose.and_then(|_| match pose_buffer { @@ -4739,6 +8331,12 @@ fn static_preview_mesh_and_materials( node1_pose, ) { root_shadow_spheres.push(sphere); + root_shadow_sphere_sources.push(DynamicShadowSphereSource { + component_index, + center: model.validated.bounding_sphere_center, + radius: model.validated.bounding_sphere_radius, + mounted: mount_pose.is_some(), + }); } } let selector_remap = static_preview_component_materials( @@ -4750,9 +8348,9 @@ fn static_preview_mesh_and_materials( &mut material_animations, )?; let lightmap_textures = static_preview_lightmap_textures(assets, visual)?; + let range_start = mesh.draw_ranges.len(); let vertex_start = mesh.vertices.len(); let vertex_count = component.vertices.len(); - let range_start = mesh.draw_ranges.len(); append_static_preview_component_with_lightmaps( &mut mesh, component, @@ -4763,55 +8361,94 @@ fn static_preview_mesh_and_materials( )?; for range_index in range_start..mesh.draw_ranges.len() { native_handedness_by_range.insert(range_index, component_handedness); + if model.validated.type16_present { + type16_present_ranges.insert(range_index); + } } - if root_sun_eligible { - sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len()); + if root_native_kind.is_some() { + root_shadow_draw_range_indices.extend(range_start..mesh.draw_ranges.len()); } - if timeline_moves_component && vertex_count != 0 { - dynamic_component_geometry.push(DynamicComponentGeometry { - object_index, - component_index, - model: runtime_model.as_ref().map(Arc::clone).ok_or_else(|| { - "animated component model was not retained".to_string() - })?, - transform, - scale: mission_root.scale, + if timeline_moves_component { + let parent_mount = tree + .as_ref() + .and_then(|nodes| nodes.get(component_index)) + .and_then(|node| node.parent_index) + .filter(|parent_index| { + component_mount_poses[*parent_index].is_some() + || component_refs.get(*parent_index).is_some_and( + |(parent_visual, _)| { + unit_component_is_visible( + *parent_index, + parent_visual.source_type, + ) + }, + ) + }) + .map(|parent_index| { + let socket = usize::try_from(records[component_index].parent_or_link) + .expect("mount socket was validated above"); + (parent_index, socket) + }); + dynamic_component_geometry[component_index] = Some(DynamicComponentGeometry { + model: model.validated.clone(), + root_transform: transform, + owner_scale: mission_root.scale, + parent_mount, vertex_start, vertex_count, - shadow_triangle_range, }); } + if root_sun_eligible { + sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len()); + } mesh_components += 1; } } - component_pose_buffers_by_object.push(component_pose_buffers); - component_mount_poses_by_object.push(component_mount_poses); - let shadow_caster_index = if let Some(native_kind) = + if let Some(native_kind) = root_native_kind.filter(|kind| native_shadow_caster_enabled(*kind)) { let caster_id = u32::try_from(shadow_casters.len()) .map_err(|_| "shadow caster id exceeds u32".to_string())?; - aggregate_shadow_caster( + let root_transform = LegacyIron3dEulerTransform { + translation: mission_root.position, + orientation_radians: mission_root.orientation_raw, + }; + if let Some(caster) = aggregate_shadow_caster( caster_id, native_kind, - LegacyIron3dEulerTransform { - translation: mission_root.position, - orientation_radians: mission_root.orientation_raw, - }, + root_transform, mission_root.scale, root_shadow_triangles, &root_shadow_spheres, - ) - .map(|caster| { - let index = shadow_casters.len(); - shadow_sphere_baselines.push(caster.sphere); + ) { + if dynamic_component_geometry.iter().any(Option::is_some) { + let component_draw_range_indices = root_shadow_draw_range_indices + .into_iter() + .filter(|range_index| { + mesh.draw_ranges + .get(*range_index) + .is_some_and(|range| range.index_count != 0) + }) + .collect::<Vec<_>>(); + if !component_draw_range_indices.is_empty() { + dynamic_shadow_casters.push(DynamicShadowCaster { + caster_index: shadow_casters.len(), + object_index, + root_transform, + owner_scale: mission_root.scale, + initial_sphere: caster.sphere, + component_draw_range_indices, + component_spheres: root_shadow_sphere_sources.clone(), + }); + } + } shadow_casters.push(caster); - index - }) - } else { - None - }; - shadow_caster_indices_by_object.push(shadow_caster_index); + } + } + component_pose_buffers_by_object.push(component_pose_buffers); + component_mount_poses_by_object.push(component_mount_poses); + dynamic_component_geometry_by_object.push(dynamic_component_geometry); + control_animation_instances_by_object.push(control_animation_instances); } if mesh_components == 0 { return Err("selected static preview roots have no mesh-backed visual".to_string()); @@ -4837,20 +8474,22 @@ fn static_preview_mesh_and_materials( Ok(StaticPreviewScene { mesh, native_handedness_by_range, + type16_present_ranges, materials, material_animations, + ambient_effect_slots, + control_point_light_slots, + component_pose_buffers: component_pose_buffers_by_object, + component_mount_poses: component_mount_poses_by_object, + dynamic_component_geometry: dynamic_component_geometry_by_object, + building_collision_bindings, + control_animation_instances: control_animation_instances_by_object, + dynamic_shadow_casters, sun_occlusion_range_indices, shadow_casters, - shadow_sphere_baselines, shadow_receivers, shadow_material_index, - terrain: Arc::new(terrain.clone()), - component_pose_buffers: component_pose_buffers_by_object, - component_mount_poses: component_mount_poses_by_object, - control_animation_instances, - dynamic_component_geometry, - dynamic_component_mounts, - shadow_caster_indices_by_object, + terrain: Arc::new(terrain_query), camera, view_camera, captured_viewport, @@ -5076,9 +8715,12 @@ fn static_preview_terrain_base_materials( initial_phase_index: static_material_phase .map_or(0, usize::from) .min(base_document.phases.len().saturating_sub(1)), + normalized_phase_override: None, + ambient_fx_material_alpha: false, variants: vec![MaterialVariantSet { phase_material_indices, range_indices: Vec::new(), + pipeline_category: base_phases[initial_phase_index].1.pipeline_category, }], base: MaterialPhaseBinding { document: base_document, @@ -5226,9 +8868,12 @@ fn static_preview_component_materials( .map_err(|_| "MAT0 phase variant selector exceeds u16".to_string())?; material_animations.push(MaterialAnimationBinding { initial_phase_index, + normalized_phase_override: None, + ambient_fx_material_alpha: false, variants: vec![MaterialVariantSet { phase_material_indices, range_indices: Vec::new(), + pipeline_category: material.pipeline_category, }], base: MaterialPhaseBinding { document: material.mat0.clone(), @@ -5343,7 +8988,7 @@ fn append_static_preview_component_with_lightmaps( // advances to another phase. The range's original selector identifies // the base binding, while the lightmap selector identifies the secondary // texture. Keep the generated phase family and the binding/variant it - // belongs to so the pending MAT0 packet can rebind every frame. + // belongs to so update_material_animations can rebind every frame. let mut lightmap_materials: HashMap<(usize, u8), (Vec<usize>, usize, usize)> = HashMap::new(); for range in component.draw_ranges { let source_material_index = selector_remap @@ -5375,6 +9020,7 @@ fn append_static_preview_component_with_lightmaps( "static preview material selector {source_material_index} has no MAT0 animation binding" ) })?; + let initial_phase_index = bindings[binding_index].initial_phase_index; let lightmap = lightmap_textures .get(usize::from(range.lightmap_index)) .expect("lightmap presence checked above"); @@ -5414,6 +9060,16 @@ fn append_static_preview_component_with_lightmaps( materials.push(lightmap_material); phase_material_indices.push(usize::from(index)); } + let initial_selector = phase_material_indices + .get(initial_phase_index) + .copied() + .ok_or_else(|| "static preview lightmap phase table is empty".to_string())?; + let pipeline_category = materials + .get(initial_selector) + .map(|material| material.pipeline_category) + .ok_or_else(|| { + "static preview lightmap material selector is unavailable".to_string() + })?; let bindings = material_animations.as_deref_mut().ok_or_else(|| { "static preview lightmap animation storage is unavailable".to_string() })?; @@ -5421,6 +9077,7 @@ fn append_static_preview_component_with_lightmaps( bindings[binding_index].variants.push(MaterialVariantSet { phase_material_indices: phase_material_indices.clone(), range_indices: Vec::new(), + pipeline_category, }); let value = (phase_material_indices, binding_index, variant_index); lightmap_materials.insert(key, value.clone()); @@ -5527,11 +9184,12 @@ fn run_static_vulkan_mode( environment: Option<DynamicEnvironment>, audio: Option<audio::GameAudio>, target_frames: u64, - control_clock_target_ms: Option<u32>, mission: &str, object_count: usize, readback_out: Option<&std::path::Path>, + readback_sequence_out: Option<&std::path::Path>, enable_validation: bool, + control_clock_target_ms: Option<u32>, startup_timings: StartupTimings, ) -> Result<String, String> { let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; @@ -5546,7 +9204,7 @@ fn run_static_vulkan_mode( preview.shadow_material_index, shadow_placeholder_vertices(), quad_indices(1)?, - world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + projected_shadow_pipeline_state(), )?; let environment_gpu = match environment.as_mut() { Some(environment) => { @@ -5557,8 +9215,11 @@ fn run_static_vulkan_mode( Some(EnvironmentGpuScene::new( &mut preview.mesh, &mut preview.native_handedness_by_range, + &mut preview.type16_present_ranges, &mut preview.materials, &mut preview.material_animations, + &mut preview.ambient_effect_slots, + &mut preview.dynamic_shadow_casters, &mut preview.sun_occlusion_range_indices, &environment_materials, &material_assets, @@ -5580,11 +9241,12 @@ fn run_static_vulkan_mode( shadow_base_index, audio, target_frames, - control_clock_target_ms, mission, object_count, readback_out.map(std::path::Path::to_path_buf), + readback_sequence_out.map(std::path::Path::to_path_buf), enable_validation, + control_clock_target_ms, startup_timings, ); if let Err(err) = event_loop.run_app(&mut app) { @@ -5593,96 +9255,23 @@ fn run_static_vulkan_mode( app.finish() } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SceneSampleId { - Live(u64), - Replay(u64), -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct FrameClockSample { - id: SceneSampleId, - time_ms: u32, - delta_seconds: f32, -} - -#[derive(Clone, Debug, PartialEq)] -struct SceneFrameUpdate { - sample: FrameClockSample, - camera: PreviewCameraFrame, - viewport: [f32; 2], - aspect: f32, - material_update: MaterialFrameUpdate, - environment_material_update: Option<MaterialFrameUpdate>, - environment: Option<DynamicEnvironmentFrame>, -} - -#[derive(Debug, Default)] -struct SceneFrameLifecycle { - pending: Option<SceneFrameUpdate>, - frames_presented: u64, - last_replay_time_ms: u32, -} - -impl SceneFrameLifecycle { - fn sample_replay(&self, target_ms: u32, target_frames: u64) -> FrameClockSample { - if let Some(pending) = self.pending.as_ref() { - return pending.sample; - } - let time_ms = control_clock_ms_for_frame(target_ms, self.frames_presented, target_frames); - FrameClockSample { - id: SceneSampleId::Replay(self.frames_presented), - time_ms, - delta_seconds: replay_environment_delta_seconds(self.last_replay_time_ms, time_ms), - } - } - - fn prepare(&mut self, update: SceneFrameUpdate) -> bool { - if self.pending.is_some() { - return false; - } - self.pending = Some(update); - true - } - - fn record_presented(&mut self) -> Option<u64> { - let sample = self.pending.take()?.sample; - let frame_index = self.frames_presented; - self.frames_presented = self.frames_presented.saturating_add(1); - if matches!(sample.id, SceneSampleId::Replay(_)) { - self.last_replay_time_ms = sample.time_ms; - } - Some(frame_index) - } -} - -fn control_clock_ms_for_frame(target_ms: u32, frame_index: u64, target_frames: u64) -> u32 { - if target_frames <= 1 { - return target_ms; - } - let last_frame = u128::from(target_frames - 1); - let frame = u128::from(frame_index).min(last_frame); - ((u128::from(target_ms) * frame) / last_frame) as u32 -} - -fn replay_environment_delta_seconds(last_applied_ms: u32, current_ms: u32) -> f32 { - current_ms.saturating_sub(last_applied_ms) as f32 / 1_000.0 -} - struct StaticVulkanApp { mesh: VulkanStaticMesh, native_handedness_by_range: HashMap<usize, i8>, + type16_present_ranges: HashSet<usize>, materials: Vec<VulkanStaticMaterial>, material_animations: Vec<MaterialAnimationBinding>, - sun_occlusion_range_indices: Vec<usize>, - terrain: Arc<TerrainWorld>, + ambient_effect_slots: Vec<AmbientEffectGpuSlot>, + control_point_light_slots: Vec<ControlPointLightSlot>, + control_effect_random_states: HashMap<ControlEffectInstanceKey, ControlEffectRandomState>, component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>, component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>, - control_animation_instances: Vec<ControlAnimationInstance>, - dynamic_component_geometry: Vec<DynamicComponentGeometry>, - dynamic_component_mounts: Vec<DynamicComponentMount>, - shadow_caster_indices_by_object: Vec<Option<usize>>, - shadow_sphere_baselines: Vec<ShadowSphere>, + dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>, + building_collision_bindings: Vec<BuildingCollisionBinding>, + control_animation_instances: Vec<Vec<Option<ControlAnimationInstance>>>, + dynamic_shadow_casters: Vec<DynamicShadowCaster>, + sun_occlusion_range_indices: Vec<usize>, + terrain: Arc<TerrainWorld>, world_range_indices: Vec<usize>, world_range_index_counts: HashMap<usize, u32>, camera: VulkanStaticCamera, @@ -5699,11 +9288,16 @@ struct StaticVulkanApp { shadow_receivers: Vec<ShadowTriangle>, shadow_cache: ShadowPageCache, shadow_last_evidence: ShadowFrameEvidence, - material_time_ms: u32, + last_environment_control_clock_ms: u32, + control_point_lights_diagnostic_reported: bool, + simulation_last_tick: Instant, + live_clock_paused_for_zero_extent: bool, + simulation_elapsed: Duration, + simulation_time_ms: u32, + simulation_update_generation: u64, + pending_scene_update: Option<SceneFrameUpdate>, environment_primitive_count: usize, pressed_keys: HashSet<KeyCode>, - last_tick: Instant, - simulation_elapsed: Duration, mouse_look: bool, audio: Option<audio::GameAudio>, camera_mode: &'static str, @@ -5714,12 +9308,13 @@ struct StaticVulkanApp { mission: String, object_count: usize, readback_out: Option<PathBuf>, + readback_sequence_out: Option<PathBuf>, enable_validation: bool, startup_timings: StartupTimings, window_id: Option<WindowId>, window: Option<Window>, renderer: Option<VulkanSmokeRenderer>, - lifecycle: SceneFrameLifecycle, + frames_presented: u64, output: Option<String>, error: Option<String>, } @@ -5734,21 +9329,28 @@ impl StaticVulkanApp { shadow_base_index: usize, audio: Option<audio::GameAudio>, target_frames: u64, - control_clock_target_ms: Option<u32>, mission: &str, object_count: usize, readback_out: Option<PathBuf>, + readback_sequence_out: Option<PathBuf>, enable_validation: bool, + control_clock_target_ms: Option<u32>, startup_timings: StartupTimings, ) -> Self { let mut fixed_range_indices = environment_gpu .as_ref() .map(EnvironmentGpuScene::fixed_range_indices) .unwrap_or_default(); + fixed_range_indices.extend(preview.ambient_effect_slots.iter().flat_map(|slot| { + std::iter::once(slot.range.range_index).chain( + slot.additional_particle_ranges + .iter() + .map(|range| range.range_index), + ) + })); fixed_range_indices.insert(shadow_range.range_index); - let world_range_indices = (0..preview.mesh.draw_ranges.len()) - .filter(|range_index| !fixed_range_indices.contains(range_index)) - .collect(); + let world_range_indices = + world_draw_range_indices(preview.mesh.draw_ranges.len(), &fixed_range_indices); let world_range_index_counts = preview .mesh .draw_ranges @@ -5760,17 +9362,20 @@ impl StaticVulkanApp { Self { mesh: preview.mesh, native_handedness_by_range: preview.native_handedness_by_range, + type16_present_ranges: preview.type16_present_ranges, materials: preview.materials, material_animations: preview.material_animations, - sun_occlusion_range_indices: preview.sun_occlusion_range_indices, - terrain: preview.terrain, + ambient_effect_slots: preview.ambient_effect_slots, + control_point_light_slots: preview.control_point_light_slots, + control_effect_random_states: HashMap::new(), component_pose_buffers: preview.component_pose_buffers, component_mount_poses: preview.component_mount_poses, - control_animation_instances: preview.control_animation_instances, dynamic_component_geometry: preview.dynamic_component_geometry, - dynamic_component_mounts: preview.dynamic_component_mounts, - shadow_caster_indices_by_object: preview.shadow_caster_indices_by_object, - shadow_sphere_baselines: preview.shadow_sphere_baselines, + building_collision_bindings: preview.building_collision_bindings, + control_animation_instances: preview.control_animation_instances, + dynamic_shadow_casters: preview.dynamic_shadow_casters, + sun_occlusion_range_indices: preview.sun_occlusion_range_indices, + terrain: preview.terrain, world_range_indices, world_range_index_counts, camera: preview.camera, @@ -5787,11 +9392,16 @@ impl StaticVulkanApp { shadow_receivers: preview.shadow_receivers, shadow_cache: ShadowPageCache::default(), shadow_last_evidence: ShadowFrameEvidence::default(), - material_time_ms: 0, + last_environment_control_clock_ms: 0, + control_point_lights_diagnostic_reported: false, + simulation_last_tick: Instant::now(), + live_clock_paused_for_zero_extent: false, + simulation_elapsed: Duration::ZERO, + simulation_time_ms: 0, + simulation_update_generation: 0, + pending_scene_update: None, environment_primitive_count: 0, pressed_keys: HashSet::new(), - last_tick: Instant::now(), - simulation_elapsed: Duration::ZERO, mouse_look: false, audio, camera_mode: preview.camera_mode, @@ -5802,12 +9412,13 @@ impl StaticVulkanApp { mission: mission.to_string(), object_count, readback_out, + readback_sequence_out, enable_validation, startup_timings, window_id: None, window: None, renderer: None, - lifecycle: SceneFrameLifecycle::default(), + frames_presented: 0, output: None, error: None, } @@ -5816,7 +9427,19 @@ impl StaticVulkanApp { fn configure_world_draw_ranges( &self, renderer: &mut VulkanSmokeRenderer, + aspect: f32, ) -> Result<(), String> { + let approximate_portals = self + .world_range_indices + .iter() + .filter(|&&index| { + let flags = self.mesh.draw_ranges[index].batch_flags; + flags & 8 != 0 + && flags & 0x50 != 0x40 + && !self.type16_present_ranges.contains(&index) + }) + .count(); + eprintln!("render_coverage portal_fade_packet_selection_approx={approximate_portals} (first indexed vertex; native CPU clipping selection pending)"); for &range_index in &self.world_range_indices { let range = self .mesh @@ -5827,7 +9450,20 @@ impl StaticVulkanApp { .materials .get(usize::from(range.material_index)) .map_or(1.0, |material| material.diffuse_alpha); - let transparent = native_world_transparent(range, alpha); + let alpha_override = native_msh_diffuse_alpha_override( + &self.mesh, + range_index, + self.type16_present_ranges.contains(&range_index), + self.view_camera.position, + horizontal_fov(&self.view_camera, aspect), + )?; + let effective_alpha = alpha_override.unwrap_or(alpha); + renderer + .set_draw_range_diffuse_alpha_override(range_index, alpha_override) + .map_err(|error| { + format!("configure world draw range {range_index} alpha: {error}") + })?; + let transparent = native_world_transparent(range, effective_alpha); renderer .set_draw_range_transparency(range_index, transparent) .map_err(|error| format!("configure world draw range {range_index}: {error}"))?; @@ -5848,7 +9484,7 @@ impl StaticVulkanApp { &self.mesh, range, range.index_count, - alpha, + effective_alpha, self.view_camera.position, self.native_handedness_by_range.get(&range_index).copied(), ), @@ -5860,13 +9496,58 @@ impl StaticVulkanApp { Ok(()) } + fn configure_ambient_effect_draw_ranges( + &self, + renderer: &mut VulkanSmokeRenderer, + ) -> Result<(), String> { + for slot in &self.ambient_effect_slots { + for (effect_range, _) in ambient_effect_ranges(slot) { + let range = self + .mesh + .draw_ranges + .get(effect_range.range_index) + .ok_or_else(|| "ambient FX draw range index is out of bounds".to_string())?; + let category = self + .materials + .get(usize::from(range.material_index)) + .ok_or_else(|| "ambient FX draw range has no MAT0 selector".to_string())? + .pipeline_category; + renderer + .set_draw_range_transparency(effect_range.range_index, true) + .map_err(|error| { + format!( + "configure ambient FX draw range {}: {error}", + effect_range.range_index + ) + })?; + renderer + .set_draw_range_material_category(effect_range.range_index, category) + .map_err(|error| format!("configure ambient FX material category: {error}"))?; + } + } + for (range_index, sort_key) in ambient_effect_draw_sort_keys( + &self.mesh, + &self.ambient_effect_slots, + self.view_camera.position, + )? { + renderer + .set_draw_range_sort_key(range_index, sort_key) + .map_err(|error| { + format!("configure ambient FX draw range {range_index} sort key: {error}") + })?; + } + Ok(()) + } + fn update_world_transparency( mesh: &VulkanStaticMesh, materials: &[VulkanStaticMaterial], world_range_indices: &[usize], + type16_present_ranges: &HashSet<usize>, world_range_index_counts: &HashMap<usize, u32>, native_handedness_by_range: &HashMap<usize, i8>, eye_world: [f32; 3], + horizontal_fov: f32, active_materials: &HashMap<usize, ActiveMaterialState>, renderer: &mut VulkanSmokeRenderer, ) -> Result<(), String> { @@ -5876,7 +9557,7 @@ impl StaticVulkanApp { .get(range_index) .ok_or_else(|| "world draw range index is out of bounds".to_string())?; let state = active_materials.get(&range_index); - let alpha = state.map_or_else( + let material_alpha = state.map_or_else( || { materials .get(usize::from(range.material_index)) @@ -5884,6 +9565,17 @@ impl StaticVulkanApp { }, |state| state.diffuse_alpha, ); + let alpha_override = native_msh_diffuse_alpha_override( + mesh, + range_index, + type16_present_ranges.contains(&range_index), + eye_world, + horizontal_fov, + )?; + let alpha = alpha_override.unwrap_or(material_alpha); + renderer + .set_draw_range_diffuse_alpha_override(range_index, alpha_override) + .map_err(|error| format!("update world draw range {range_index} alpha: {error}"))?; renderer .set_draw_range_transparency(range_index, native_world_transparent(range, alpha)) .map_err(|error| format!("update world draw range {range_index}: {error}"))?; @@ -5915,188 +9607,107 @@ impl StaticVulkanApp { } fn update_free_camera(&mut self, delta_seconds: f32, viewport: [f32; 2]) -> Result<(), String> { - if self.free_camera.is_none() { + let Some(mut free_camera) = self.free_camera else { return Ok(()); - } - let aspect = viewport[0] / viewport[1].max(1.0); - let view_camera = { - let free_camera = self - .free_camera - .as_mut() - .ok_or_else(|| "free-flight camera disappeared".to_string())?; - free_camera.advance(&self.pressed_keys, delta_seconds.min(0.1)); - free_camera.keep_above_terrain(&self.terrain)?; - free_camera.preview_frame(aspect) }; + let aspect = viewport[0] / viewport[1].max(1.0); + let safe_position = free_camera.position; + free_camera.advance(&self.pressed_keys, delta_seconds.clamp(0.0, 0.1)); + free_camera.keep_above_terrain(&self.terrain)?; + if free_camera.position != safe_position + && self.building_segment_blocked([safe_position, free_camera.position])? + { + free_camera.position = safe_position; + } + let view_camera = free_camera.preview_frame(aspect); let camera = view_camera.vulkan_camera(); + self.free_camera = Some(free_camera); self.view_camera = view_camera; self.camera = camera; - let sort_keys = self.world_draw_sort_keys(view_camera.position)?; - if let Some(renderer) = self.renderer.as_mut() { - renderer - .set_camera(camera) - .map_err(|error| format!("update free-flight camera: {error}"))?; - for (range_index, sort_key) in sort_keys { - renderer - .set_draw_range_sort_key(range_index, sort_key) - .map_err(|error| { - format!("update world draw range {range_index} sort key: {error}") - })?; - } - } Ok(()) } - fn update_material_clock(&mut self, sample: FrameClockSample) { - self.material_time_ms = sample.time_ms; - } - - fn update_controlled_component_geometry( - &mut self, - sample: FrameClockSample, - ) -> Result<(), String> { - let mut pose_changed = false; - for instance in &mut self.control_animation_instances { - let pose = static_preview_control_pose_buffer( - &instance.model, - &instance.control, - sample.time_ms, - &mut instance.state, - )?; - let slot = self - .component_pose_buffers - .get_mut(instance.object_index) - .and_then(|buffers| buffers.get_mut(instance.component_index)) - .ok_or_else(|| { - format!( - "animated component {} pose slot is outside object {}", - instance.component_index, instance.object_index - ) - })?; - if *slot != pose { - *slot = pose; - pose_changed = true; + fn building_segment_blocked(&self, world_segment: [[f32; 3]; 2]) -> Result<bool, String> { + for binding in &self.building_collision_bindings { + if binding.selected_nodes.is_empty() { + continue; } - } - if !pose_changed { - return Ok(()); - } - - // Mounts are recalculated from the sampled hierarchy even when the - // parent has no visible draw geometry of its own. - let mounts_changed = refresh_dynamic_component_mounts( - &self.dynamic_component_mounts, - &self.component_pose_buffers, - &mut self.component_mount_poses, - )?; - let mut geometry_changed = false; - let mut shadow_updates = Vec::new(); - let mut updated_shadow_casters = HashSet::new(); - for dynamic in &self.dynamic_component_geometry { - let pose = self + let pose_buffer = self .component_pose_buffers - .get(dynamic.object_index) - .and_then(|buffers| buffers.get(dynamic.component_index)) - .and_then(Option::as_ref) - .ok_or_else(|| { - format!( - "animated component {} has no current Node38 hierarchy", - dynamic.component_index - ) - })?; - let mount = self + .get(binding.object_index) + .and_then(|components| components.get(binding.component_index)) + .and_then(Option::as_ref); + let mount_pose = self .component_mount_poses - .get(dynamic.object_index) - .and_then(|poses| poses.get(dynamic.component_index)) + .get(binding.object_index) + .and_then(|components| components.get(binding.component_index)) .copied() .flatten(); - let projected = project_dynamic_component_geometry(dynamic, pose, mount)?; - if projected.vertices.len() != dynamic.vertex_count { - return Err(format!( - "animated component {} changed projected vertex count from {} to {}", - dynamic.component_index, - dynamic.vertex_count, - projected.vertices.len() - )); - } - let end = dynamic - .vertex_start - .checked_add(dynamic.vertex_count) - .ok_or_else(|| "animated component vertex range overflowed".to_string())?; - let destination = self - .mesh - .vertices - .get_mut(dynamic.vertex_start..end) - .ok_or_else(|| { + let nodes = building_collision_nodes(binding, pose_buffer, mount_pose)?; + if query_building_segment_world(&binding.model, &nodes, world_segment) + .map_err(|error| { format!( - "animated component {} vertex range is outside the shared mesh", - dynamic.component_index + "query building collision object {} component {} model {}: {error}", + binding.object_index, binding.component_index, binding.model_source ) - })?; - if destination != projected.vertices.as_slice() { - destination.copy_from_slice(&projected.vertices); - geometry_changed = true; - } - if let Some(range) = dynamic.shadow_triangle_range.clone() { - let triangles = static_mesh_shadow_triangles(&projected); - if triangles.len() != range.len() { - return Err(format!( - "animated component {} changed shadow triangle count from {} to {}", - dynamic.component_index, - range.len(), - triangles.len() - )); - } - shadow_updates.push((dynamic.object_index, range, triangles)); + })? + .is_some() + { + return Ok(true); } } - for (object_index, range, triangles) in shadow_updates { - let Some(caster_index) = self - .shadow_caster_indices_by_object - .get(object_index) - .copied() - .flatten() - else { - continue; + Ok(false) + } + + fn sample_frame_clock(&mut self) -> FrameClockSample { + if let Some(target_ms) = self.control_clock_target_ms { + let time_ms = + control_clock_ms_for_frame(target_ms, self.frames_presented, self.target_frames); + self.simulation_elapsed = Duration::from_millis(u64::from(time_ms)); + self.simulation_time_ms = time_ms; + return FrameClockSample { + id: SceneSampleId::Replay(self.frames_presented), + time_ms, + delta_seconds: replay_environment_delta_seconds( + self.last_environment_control_clock_ms, + time_ms, + ), }; - let caster = self - .shadow_casters - .get_mut(caster_index) - .ok_or_else(|| format!("shadow caster {caster_index} is unavailable"))?; - let destination = caster.triangles.get_mut(range).ok_or_else(|| { - format!("animated component shadow range is outside caster {caster_index}") - })?; - destination.clone_from_slice(&triangles); - updated_shadow_casters.insert(caster_index); } - for caster_index in updated_shadow_casters { - let baseline = self - .shadow_sphere_baselines - .get(caster_index) - .copied() - .ok_or_else(|| format!("shadow sphere baseline {caster_index} is unavailable"))?; - let caster = self - .shadow_casters - .get_mut(caster_index) - .ok_or_else(|| format!("shadow caster {caster_index} is unavailable"))?; - // shortcut: native animated sphere updates are unverified; derive - // a conservative envelope from the native startup sphere and all - // current triangles, never from a prior frame's expanded bounds. - caster.sphere = shadow_sphere_enclosing_triangles(baseline, &caster.triangles)?; - } - if geometry_changed || mounts_changed { - let sort_keys = self.world_draw_sort_keys(self.view_camera.position)?; - if let Some(renderer) = self.renderer.as_mut() { - for (range_index, sort_key) in sort_keys { - renderer - .set_draw_range_sort_key(range_index, sort_key) - .map_err(|error| { - format!("update animated component sort key {range_index}: {error}") - })?; - } - } + self.sample_live_frame_clock_at(Instant::now()) + } + + fn pending_or_sample_frame_clock(&mut self) -> FrameClockSample { + match self.pending_scene_update.as_ref() { + Some(pending) => pending.clock, + None => self.sample_frame_clock(), + } + } + + fn sample_live_frame_clock_at(&mut self, now: Instant) -> FrameClockSample { + let elapsed = now.saturating_duration_since(self.simulation_last_tick); + self.simulation_last_tick = now; + self.simulation_elapsed = self.simulation_elapsed.saturating_add(elapsed); + self.simulation_time_ms = self.simulation_elapsed.as_millis() as u32; + let sample_id = SceneSampleId::Live(self.simulation_update_generation); + self.simulation_update_generation = self.simulation_update_generation.saturating_add(1); + FrameClockSample { + id: sample_id, + time_ms: self.simulation_time_ms, + delta_seconds: elapsed.as_secs_f32(), + } + } + + fn pause_live_clock_at(&mut self, now: Instant) { + self.live_clock_paused_for_zero_extent = true; + self.simulation_last_tick = now; + } + + fn resume_live_clock_at(&mut self, now: Instant) { + if self.live_clock_paused_for_zero_extent { + self.simulation_last_tick = now; + self.live_clock_paused_for_zero_extent = false; } - Ok(()) } fn update_shadow( @@ -6108,12 +9719,14 @@ impl StaticVulkanApp { shadow_base_vertex: usize, shadow_base_index: usize, shadow_material_index: usize, + uploaded_shadow_atlas: &mut VulkanStaticTexture, renderer: &mut VulkanSmokeRenderer, + control_point_lights: &[(VulkanPointLight, u32)], sky_frame: Option<&SkyFrame<'_>>, environment_frame: Option<&EnvironmentFrame>, shadow_camera: Option<ShadowCamera>, ) -> Result<ShadowFrameEvidence, String> { - let lights = shadow_lights(sky_frame, environment_frame, renderer); + let lights = shadow_lights(sky_frame, environment_frame, renderer, control_point_lights); let visible_casters = native_visible_shadow_casters( shadow_casters, renderer.frame_uniforms().clip_from_world, @@ -6137,9 +9750,12 @@ impl StaticVulkanApp { &frame, )?; let atlas = shadow_atlas_texture_from_frame(&frame); - renderer - .update_material_texture(shadow_material_index, 0, &atlas) - .map_err(|error| format!("upload projected shadow atlas: {error}"))?; + if atlas != *uploaded_shadow_atlas { + renderer + .update_material_texture(shadow_material_index, 0, &atlas) + .map_err(|error| format!("upload projected shadow atlas: {error}"))?; + *uploaded_shadow_atlas = atlas; + } renderer .update_mesh_geometry(&mesh.vertices, &mesh.indices, &[geometry]) .map_err(|error| format!("upload environment and shadow geometry: {error}"))?; @@ -6149,88 +9765,502 @@ impl StaticVulkanApp { }) } - fn update_environment( + fn prepare_scene_update( &mut self, - sample: FrameClockSample, - viewport: [f32; 2], - ) -> Result<Option<DynamicEnvironmentFrame>, String> { - if self.renderer.is_none() { - return Ok(None); + clock: FrameClockSample, + drawable_viewport: [f32; 2], + ) -> Result<(), String> { + let viewport = self.captured_viewport.map_or_else( + || [drawable_viewport[0].max(1.0), drawable_viewport[1].max(1.0)], + |viewport| [viewport.extent[0] as f32, viewport.extent[1] as f32], + ); + let aspect = viewport[0] / viewport[1].max(1.0); + if let Some((pending_id, pending_viewport)) = self + .pending_scene_update + .as_ref() + .map(|pending| (pending.clock.id, pending.viewport)) + { + if pending_id == clock.id { + if pending_viewport != viewport { + self.refresh_pending_scene_viewport(viewport, aspect)?; + } + return Ok(()); + } } + let control_clock_ms = clock.time_ms; + let effect_elapsed_seconds = control_clock_ms as f32 / 1000.0; + // CTLD's validated NDP defaults seed the native query gate. This + // preview does not process mission Action1 updates, so it retains that + // initial provider value for the capture. + update_control_animation_pose_buffers( + &mut self.control_animation_instances, + &mut self.component_pose_buffers, + control_clock_ms, + )?; + update_dynamic_component_geometry( + &self.dynamic_component_geometry, + &self.component_pose_buffers, + &mut self.component_mount_poses, + &mut self.mesh, + )?; + self.update_free_camera(clock.delta_seconds, viewport)?; + refresh_dynamic_shadow_casters( + &mut self.shadow_casters, + &self.dynamic_shadow_casters, + &self.mesh, + &self.component_pose_buffers, + &self.component_mount_poses, + )?; + for slot in &mut self.ambient_effect_slots { + if slot.owner_component_is_mounted { + slot.mount_pose = self + .component_mount_poses + .get(slot.owner_object_index) + .and_then(|components| components.get(slot.owner_component_index)) + .copied() + .flatten(); + } + } + let control_effect_random_samples = advance_control_effect_random_states( + &mut self.control_effect_random_states, + &self.ambient_effect_slots, + &self.control_point_light_slots, + clock.id, + control_clock_ms, + effect_elapsed_seconds, + ); + let control_point_lights = update_control_point_lights( + &mut self.control_point_light_slots, + &self.component_pose_buffers, + &self.component_mount_poses, + effect_elapsed_seconds, + &control_effect_random_samples, + std::env::var_os("FPARKAN_CONTROL_POINT_LIGHT_DIAGNOSTIC").is_some() + && !self.control_point_lights_diagnostic_reported + && self + .control_clock_target_ms + .is_none_or(|target_ms| control_clock_ms >= target_ms), + ); + let control_vulkan_lights = ordinary_control_point_lights(&control_point_lights); + let effect_updates = update_ambient_effect_geometry( + &mut self.ambient_effect_slots, + &self.component_pose_buffers, + &mut self.mesh, + &mut self.material_animations, + effect_elapsed_seconds, + &control_effect_random_samples, + self.view_camera.position, + )?; + let world_sort_keys = self.world_draw_sort_keys(self.view_camera.position)?; + let ambient_sort_keys = ambient_effect_draw_sort_keys( + &self.mesh, + &self.ambient_effect_slots, + self.view_camera.position, + )?; let camera = self.view_camera; + let material_update = sample_material_animations( + &mut self.material_animations, + &self.materials, + clock.time_ms, + )?; + let active_materials = &material_update.active_materials; + apply_ambient_effect_material_tints( + &self.ambient_effect_slots, + &mut self.mesh, + &effect_updates, + &material_update.active_materials, + )?; + let diagnostic_mode = std::env::var("FPARKAN_AMBIENT_FX_DIAGNOSTIC").ok(); + if diagnostic_mode.is_some() { + let generator_capture_is_final = diagnostic_mode.as_deref() == Some("generator") + && self + .control_clock_target_ms + .is_none_or(|target_ms| control_clock_ms >= target_ms); + for update in &effect_updates { + let Some((slot, range)) = self.ambient_effect_slots.iter().find_map(|slot| { + ambient_effect_range_for(slot, update.range_index).map(|range| (slot, range)) + }) else { + continue; + }; + if generator_capture_is_final + && slot.kind == AmbientEffectKind::GenerBall + && slot.control_source == "fortif.rlb/fr_l_gener.ctl" + { + let random_sample = control_effect_random_samples + .get(&slot.random_instance_key) + .copied() + .unwrap_or_default(); + let phase = ambient_effect_phase( + slot.time_mode, + slot.header_flags, + effect_elapsed_seconds, + slot.duration_seconds, + ) + .map(|phase| { + native_command_phase(slot.header_flags, phase, random_sample.phase_jitter) + }); + let evaluated = phase.and_then(|phase| { + evaluate_f_gener_ball( + &slot.command, + slot.command_index, + &slot.world_from_effect, + phase, + effect_elapsed_seconds, + self.view_camera.position, + ) + }); + let expected_vertices = evaluated.as_ref().map(|frame| frame.vertices); + let expected_center = + expected_vertices.map(|vertices| ambient_effect_quad_center(&vertices)); + let expected_size = + expected_vertices.map(|vertices| ambient_effect_quad_size(&vertices)); + let expected_screen = expected_vertices.and_then(|vertices| { + ambient_effect_projected_bounds( + &vertices, + self.view_camera.clip_from_world, + self.captured_viewport + .map_or([1280, 1024], |viewport| viewport.extent), + ) + }); + let actual_vertices = self + .mesh + .vertices + .get(range.vertex_start..range.vertex_start.saturating_add(4)); + let actual_center: Option<[f32; 3]> = actual_vertices.map(|vertices| { + std::array::from_fn(|axis| { + vertices + .iter() + .map(|vertex| vertex.position[axis]) + .sum::<f32>() + / 4.0 + }) + }); + let actual_rgb = actual_vertices + .and_then(|vertices| vertices.first()) + .map(|vertex| vertex.color); + let actual_alpha = actual_vertices + .and_then(|vertices| vertices.first()) + .map(|vertex| vertex.overlay_alpha); + let material = active_materials.get(&update.range_index); + eprintln!( + "ambient_fx_generator_diagnostic elapsed={effect_elapsed_seconds:.3} clock_ms={control_clock_ms} owner_object={} owner_component={} source={} table={} row={} points={:?} opcode={:?} command={} range={} submitted_indices={} effect_phase={phase:?} evaluator_active={:?} fx_mat_phase={:?} fx_alpha={:?} expected_center={expected_center:?} expected_size={expected_size:?} expected_screen={expected_screen:?} actual_center={actual_center:?} actual_rgb={actual_rgb:?} actual_alpha={actual_alpha:?} mat_selector={:?} mat_phase_index={:?} sampled_mat_rgb={:?}", + slot.owner_object_index, + slot.owner_component_index, + slot.control_source, + slot.control_table_index, + slot.control_row_index, + slot.control_point_indices, + slot.command.opcode, + slot.command_index, + update.range_index, + update.index_count, + evaluated.as_ref().map(|frame| frame.active), + evaluated.as_ref().map(|frame| frame.material_phase), + evaluated.as_ref().map(|frame| frame.color[3]), + material.map(|material| material.material_index), + material.map(|material| material.phase_index), + material.map(|material| material.additive_rgb), + ); + continue; + } + if diagnostic_mode.as_deref() == Some("generator") + || slot.control_source != "animals.rlb/a_a_l3.ctl" + || update.index_count == 0 + { + continue; + } + let start = range.vertex_start; + let vertices = self + .mesh + .vertices + .get(start..start.saturating_add(range.vertex_capacity)) + .unwrap_or_default(); + let center = if vertices.is_empty() { + [0.0; 3] + } else { + std::array::from_fn(|axis| { + vertices + .iter() + .map(|vertex| vertex.position[axis]) + .sum::<f32>() + / vertices.len() as f32 + }) + }; + let color = vertices + .first() + .map(|vertex| vertex.color) + .unwrap_or([0.0; 3]); + let alpha = vertices + .first() + .map(|vertex| vertex.overlay_alpha) + .unwrap_or(0.0); + eprintln!( + "ambient_fx_diagnostic elapsed={effect_elapsed_seconds:.3} source={} table={} row={} points={:?} owners={:?} parent_translation={:?} opcode={:?} command={} range={} active_indices={} center={center:?} command_rgb={:?} sampled_mat_rgb={color:?} alpha={alpha:.3}", + slot.control_source, + slot.control_table_index, + slot.control_row_index, + slot.control_point_indices, + slot.control_point_owners, + [slot.world_from_effect[0][3], slot.world_from_effect[1][3], slot.world_from_effect[2][3]], + slot.command.opcode, + slot.command_index, + update.range_index, + update.index_count, + update.command_rgb, + ); + } + } + if let Some(audio) = self.audio.as_mut() { audio .update_listener(camera.position, camera.forward, camera.up) .map_err(|error| format!("update audio listener: {error}"))?; } - let environment_frame = if self.environment.is_some() { - let renderer = self - .renderer - .as_mut() - .ok_or_else(|| "native Vulkan renderer was not initialized".to_string())?; - let environment = self - .environment - .as_mut() - .ok_or_else(|| "dynamic environment disappeared".to_string())?; - Some(environment.update( - sample.delta_seconds, + let environment = if let Some(environment) = self.environment.as_mut() { + Some(environment.update_cpu( + clock.delta_seconds, &camera, - viewport[0] / viewport[1].max(1.0), + aspect, viewport, - renderer, self.audio.as_mut(), )?) } else { None }; - if let Some(frame) = environment_frame.as_ref() { - self.environment_primitive_count = frame.environment_frame.primitives.len(); + self.environment_primitive_count = environment + .as_ref() + .map_or(0, |frame| frame.environment_frame.primitives.len()); + if matches!(clock.id, SceneSampleId::Replay(_)) { + self.last_environment_control_clock_ms = control_clock_ms; } - Ok(environment_frame) + let environment_material_update = + if let Some(environment_gpu) = self.environment_gpu.as_mut() { + Some(sample_material_animations( + &mut environment_gpu.material_animations, + &self.materials, + clock.time_ms, + )?) + } else { + None + }; + let environment_point_light_count = environment.as_ref().map_or(0, |frame| { + frame + .environment_frame + .point_lights + .iter() + .filter(|light| light.active) + .count() + }); + report_control_point_lights_once( + &mut self.control_point_lights_diagnostic_reported, + control_clock_ms, + self.control_clock_target_ms, + environment_point_light_count, + &control_point_lights, + &control_vulkan_lights, + ); + self.pending_scene_update = Some(SceneFrameUpdate { + clock, + camera, + viewport, + aspect, + control_point_lights, + control_vulkan_lights, + effect_updates, + world_sort_keys, + ambient_sort_keys, + material_update, + environment_material_update, + environment, + }); + Ok(()) } - fn apply_environment_view( + fn refresh_pending_scene_viewport( &mut self, - frame: Option<&DynamicEnvironmentFrame>, - environment_material_update: Option<&MaterialFrameUpdate>, - camera: PreviewCameraFrame, viewport: [f32; 2], + aspect: f32, ) -> Result<(), String> { + let Some((previous_camera, previous_aspect)) = self + .pending_scene_update + .as_ref() + .map(|pending| (pending.camera, pending.aspect)) + else { + return Ok(()); + }; + let camera = previous_camera.with_aspect(previous_aspect, aspect); + self.view_camera = camera; + self.camera = camera.vulkan_camera(); + let environment_camera = environment_camera_for_preview(&camera, aspect, viewport); + if let Some(environment) = self + .pending_scene_update + .as_mut() + .and_then(|pending| pending.environment.as_mut()) + { + environment + .environment_frame + .reproject_for_camera(environment_camera); + } + let pending = self + .pending_scene_update + .as_mut() + .expect("pending scene update was checked above"); + pending.camera = camera; + pending.viewport = viewport; + pending.aspect = aspect; + Ok(()) + } + + fn render_scene_update(&mut self) -> Result<(), String> { + let Some(frame) = self.pending_scene_update.as_ref() else { + return Ok(()); + }; let Some(renderer) = self.renderer.as_mut() else { return Ok(()); }; - let aspect = viewport[0] / viewport[1].max(1.0); - if let Some(frame) = frame { + renderer + .set_camera(frame.camera.vulkan_camera()) + .map_err(|error| format!("apply sampled scene camera: {error}"))?; + apply_material_frame_update( + &frame.material_update, + &mut self.material_animations, + renderer, + )?; + for update in &frame.effect_updates { + renderer + .set_draw_range_index_count(update.range_index, update.index_count) + .map_err(|error| { + format!( + "update ambient FX draw range {}: {error}", + update.range_index + ) + })?; + } + for &(range_index, sort_key) in &frame.world_sort_keys { + renderer + .set_draw_range_sort_key(range_index, sort_key) + .map_err(|error| { + format!("update dynamic world draw range {range_index} sort key: {error}") + })?; + } + for &(range_index, sort_key) in &frame.ambient_sort_keys { + renderer + .set_draw_range_sort_key(range_index, sort_key) + .map_err(|error| { + format!("update ambient FX draw range {range_index} sort key: {error}") + })?; + } + Self::update_world_transparency( + &self.mesh, + &self.materials, + &self.world_range_indices, + &self.type16_present_ranges, + &self.world_range_index_counts, + &self.native_handedness_by_range, + frame.camera.position, + horizontal_fov(&frame.camera, frame.aspect), + &frame.material_update.active_materials, + renderer, + )?; + let shadow_camera = ShadowCamera { + position: frame.camera.position, + viewport_width: frame.viewport[0], + horizontal_fov: horizontal_fov(&frame.camera, frame.aspect), + }; + if let Some(sampled_environment) = frame.environment.as_ref() { let environment = self .environment .as_ref() - .ok_or_else(|| "dynamic environment disappeared".to_string())?; + .ok_or_else(|| "sampled atmosphere lost its owner".to_string())?; let sky_frame = SkyFrame::from_atmosphere( - &frame.atmosphere, + &sampled_environment.atmosphere, &environment.materials, - frame.absolute_time_seconds, + sampled_environment.absolute_time_seconds, ); + let environment_frame = &sampled_environment.environment_frame; + let mut uniforms = renderer.frame_uniforms(); + uniforms.clip_from_world = frame.camera.clip_from_world; + for (target, source) in uniforms + .directional_lights + .iter_mut() + .zip(sky_frame.directional_lights) + { + target.direction = source.direction; + target.rgb = source.color; + target.coefficients = [0.0, 0.0, 1.0]; + target.active = source.active; + } + uniforms.lighting_floor = sky_frame.lighting_floor; + let fog_rgb = sky_frame + .screen_gradient(frame.camera.native_sky_heading(), [0.0; 3]) + .color + .rgba(); + uniforms.fog_color = [fog_rgb[0], fog_rgb[1], fog_rgb[2]]; + uniforms.fog_start = sky_frame.fog.start.max(0.0); + uniforms.fog_end = sky_frame.fog.end.max(uniforms.fog_start); + uniforms.camera_position = frame.camera.position; + renderer + .set_frame_uniforms(uniforms) + .map_err(|error| format!("update sky frame uniforms: {error}"))?; + let mut uniforms = renderer.frame_uniforms(); + uniforms.point_light = environment_frame + .point_lights + .iter() + .find(|light| light.active) + .map(|light| VulkanPointLight { + position: light.position, + rgb: [light.color[0], light.color[1], light.color[2]], + range: light.range, + coefficients: light.attenuation, + active: true, + }) + .unwrap_or_default(); + renderer + .set_frame_uniforms(uniforms) + .map_err(|error| format!("update environment point-light uniforms: {error}"))?; + let mut point_lights = environment_frame + .point_lights + .iter() + .filter(|light| light.active) + .map(|light| VulkanPointLight { + position: light.position, + rgb: [light.color[0], light.color[1], light.color[2]], + range: light.range, + coefficients: light.attenuation, + active: true, + }) + .collect::<Vec<_>>(); + point_lights.extend(frame.control_vulkan_lights.iter().copied()); + renderer + .set_point_lights(&point_lights) + .map_err(|error| format!("update complete scene point-light set: {error}"))?; if let Some(environment_gpu) = self.environment_gpu.as_mut() { - let material_update = environment_material_update + let material_update = frame + .environment_material_update + .as_ref() .ok_or_else(|| "sampled environment material state is missing".to_string())?; + apply_material_frame_update( + material_update, + &mut environment_gpu.material_animations, + renderer, + )?; environment_gpu.update( &mut self.mesh, renderer, material_update, &sky_frame, - &frame.sky_mesh, - &frame.environment_frame, - &camera, - viewport, + &sampled_environment.sky_mesh, + environment_frame, + &frame.camera, + frame.viewport, &self.terrain, &self.sun_occlusion_range_indices, )?; } - let shadow_camera = ShadowCamera { - position: camera.position, - viewport_width: viewport[0], - horizontal_fov: horizontal_fov(&camera, aspect), - }; + let shadow_material = self + .materials + .get_mut(self.shadow_material_index) + .ok_or_else(|| "projected shadow material is out of bounds".to_string())?; self.shadow_last_evidence = Self::update_shadow( &mut self.mesh, &mut self.shadow_cache, @@ -6240,17 +10270,21 @@ impl StaticVulkanApp { self.shadow_base_vertex, self.shadow_base_index, self.shadow_material_index, + &mut shadow_material.texture, renderer, + &frame.control_point_lights, Some(&sky_frame), - Some(&frame.environment_frame), + Some(environment_frame), Some(shadow_camera), )?; } else { - let shadow_camera = Some(ShadowCamera { - position: camera.position, - viewport_width: viewport[0], - horizontal_fov: horizontal_fov(&camera, aspect), - }); + renderer + .set_point_lights(&frame.control_vulkan_lights) + .map_err(|error| format!("update complete scene point-light set: {error}"))?; + let shadow_material = self + .materials + .get_mut(self.shadow_material_index) + .ok_or_else(|| "projected shadow material is out of bounds".to_string())?; self.shadow_last_evidence = Self::update_shadow( &mut self.mesh, &mut self.shadow_cache, @@ -6260,148 +10294,22 @@ impl StaticVulkanApp { self.shadow_base_vertex, self.shadow_base_index, self.shadow_material_index, + &mut shadow_material.texture, renderer, + &frame.control_point_lights, None, None, - shadow_camera, + Some(shadow_camera), )?; } Ok(()) } - fn refresh_pending_viewport(&mut self, viewport: [f32; 2]) -> Result<(), String> { - if self.captured_viewport.is_some() { - return Ok(()); - } - let Some(mut update) = self.lifecycle.pending.clone() else { - return Ok(()); - }; - let viewport = [viewport[0].max(1.0), viewport[1].max(1.0)]; - let aspect = viewport[0] / viewport[1]; - let camera = update.camera.with_aspect(update.aspect, aspect); - self.view_camera = camera; - self.camera = camera.vulkan_camera(); - if let Some(renderer) = self.renderer.as_mut() { - renderer - .set_camera(self.camera) - .map_err(|error| format!("refresh free-flight camera viewport: {error}"))?; - } - let environment_camera = environment_camera_for_preview(&camera, aspect, viewport); - if let Some(environment) = update.environment.as_mut() { - environment - .environment_frame - .reproject_for_camera(environment_camera); - } - update.camera = camera; - update.viewport = viewport; - update.aspect = aspect; - self.lifecycle.pending = Some(update); - Ok(()) - } - - fn apply_pending_scene_update(&mut self) -> Result<(), String> { - let Some(frame) = self.lifecycle.pending.take() else { - return Ok(()); - }; - let result = (|| { - { - let Some(renderer) = self.renderer.as_mut() else { - return Err("native Vulkan renderer was not initialized".to_string()); - }; - apply_material_frame_update( - &frame.material_update, - &mut self.material_animations, - renderer, - )?; - Self::update_world_transparency( - &self.mesh, - &self.materials, - &self.world_range_indices, - &self.world_range_index_counts, - &self.native_handedness_by_range, - frame.camera.position, - &frame.material_update.active_materials, - renderer, - )?; - if let Some(material_update) = frame.environment_material_update.as_ref() { - let environment_gpu = self.environment_gpu.as_mut().ok_or_else(|| { - "sampled environment material state lost its owner".to_string() - })?; - apply_material_frame_update( - material_update, - &mut environment_gpu.material_animations, - renderer, - )?; - } - } - self.apply_environment_view( - frame.environment.as_ref(), - frame.environment_material_update.as_ref(), - frame.camera, - frame.viewport, - ) - })(); - self.lifecycle.pending = Some(frame); - result - } - - fn sample_frame_clock(&mut self) -> FrameClockSample { - if let Some(target_ms) = self.control_clock_target_ms { - return self.lifecycle.sample_replay(target_ms, self.target_frames); - } - self.sample_live_frame_clock_at(Instant::now()) - } - - fn sample_live_frame_clock_at(&mut self, now: Instant) -> FrameClockSample { - if let Some(pending) = self.lifecycle.pending.as_ref() { - return pending.sample; - } - let elapsed = now.saturating_duration_since(self.last_tick); - self.last_tick = now; - self.simulation_elapsed = self.simulation_elapsed.saturating_add(elapsed); - FrameClockSample { - id: SceneSampleId::Live(self.lifecycle.frames_presented), - time_ms: self.simulation_elapsed.as_millis() as u32, - delta_seconds: elapsed.as_secs_f32(), - } - } - - fn prepare_scene_update( - &mut self, - sample: FrameClockSample, - viewport: [f32; 2], - ) -> Result<SceneFrameUpdate, String> { - let viewport = [viewport[0].max(1.0), viewport[1].max(1.0)]; - let aspect = viewport[0] / viewport[1]; - self.update_free_camera(sample.delta_seconds, viewport)?; - self.update_material_clock(sample); - self.update_controlled_component_geometry(sample)?; - let material_update = sample_material_animations( - &mut self.material_animations, - &self.materials, - self.material_time_ms, - )?; - let environment_material_update = self - .environment_gpu - .as_mut() - .map(|environment_gpu| { - sample_material_animations( - &mut environment_gpu.material_animations, - &self.materials, - self.material_time_ms, - ) - }) - .transpose()?; - let environment = self.update_environment(sample, viewport)?; - Ok(SceneFrameUpdate { - sample, - camera: self.view_camera, - viewport, - aspect, - material_update, - environment_material_update, - environment, - }) + fn record_presented_scene_frame(&mut self) -> u64 { + let frame_index = self.frames_presented; + self.frames_presented = self.frames_presented.saturating_add(1); + self.pending_scene_update = None; + frame_index } fn set_mouse_look(&mut self, active: bool) { @@ -6492,6 +10400,13 @@ impl StaticVulkanApp { .map_or(report.renderer_report.swapchain_image_format, |artifact| { artifact.format }); + if let Some(control_clock_ms) = self.control_clock_target_ms { + emit_control_animation_capture_diagnostics( + &self.control_animation_instances, + &self.component_pose_buffers, + control_clock_ms, + ); + } let captured_crop = self.captured_viewport.map_or_else( || "none".to_string(), |viewport| { @@ -6502,14 +10417,17 @@ impl StaticVulkanApp { }, ); self.output = Some(format!( - "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, captured_viewport_crop={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}", + "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, captured_viewport_crop={}, control_clock_ms={}, control_clock_mode={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, readback_sequence={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}", self.mission, - self.lifecycle.frames_presented, + self.frames_presented, self.object_count, self.mesh_components, self.terrain_components, self.camera_mode, captured_crop, + self.control_clock_target_ms + .map_or_else(|| "realtime".to_string(), |clock| clock.to_string()), + control_clock_mode_label(self.control_clock_target_ms), self.materials.len(), self.environment_primitive_count, self.shadow_last_evidence.visible_casters, @@ -6519,6 +10437,9 @@ impl StaticVulkanApp { report.renderer_report.swapchain_image_format, readback_format, readback_path.as_deref().unwrap_or("none"), + self.readback_sequence_out + .as_deref() + .map_or_else(|| "none".to_string(), |path| path.display().to_string()), elapsed_ms(self.startup_timings.mission_assets), elapsed_ms(self.startup_timings.terrain_materials), elapsed_ms(self.startup_timings.sky), @@ -6635,7 +10556,15 @@ impl ApplicationHandler for StaticVulkanApp { event_loop.exit(); return; } - if let Err(error) = self.configure_world_draw_ranges(&mut renderer) { + if let Err(error) = self.configure_world_draw_ranges( + &mut renderer, + size.width.max(1) as f32 / size.height.max(1) as f32, + ) { + self.error = Some(error); + event_loop.exit(); + return; + } + if let Err(error) = self.configure_ambient_effect_draw_ranges(&mut renderer) { self.error = Some(error); event_loop.exit(); return; @@ -6659,13 +10588,28 @@ impl ApplicationHandler for StaticVulkanApp { } } self.startup_timings.gpu_initialization = gpu_initialization_started.elapsed(); - renderer.set_readback_enabled(self.readback_out.is_some()); + renderer.set_readback_enabled( + self.readback_out.is_some() || self.readback_sequence_out.is_some(), + ); self.window_id = Some(window.id()); self.window = Some(window); self.renderer = Some(renderer); - self.last_tick = Instant::now(); - self.simulation_elapsed = Duration::ZERO; - self.material_time_ms = 0; + self.simulation_last_tick = Instant::now(); + self.live_clock_paused_for_zero_extent = false; + self.pending_scene_update = None; + let drawable_size = self + .window + .as_ref() + .map(Window::inner_size) + .unwrap_or_default(); + if let Err(error) = self.update_free_camera( + 0.0, + [drawable_size.width as f32, drawable_size.height as f32], + ) { + self.error = Some(error); + event_loop.exit(); + return; + } self.schedule_next_redraw(); } @@ -6736,25 +10680,27 @@ impl ApplicationHandler for StaticVulkanApp { } if drawable_size.width == 0 || drawable_size.height == 0 { event_loop.set_control_flow(ControlFlow::Wait); + self.pause_live_clock_at(Instant::now()); } else { event_loop.set_control_flow(ControlFlow::Poll); + self.resume_live_clock_at(Instant::now()); + // The pending simulation sample is reused; its view and + // viewport-dependent geometry refresh before the retry. } } WindowEvent::RedrawRequested => { - if self.lifecycle.pending.is_none() { + if self.pending_scene_update.is_none() { return; } - let drawable_available = self.window.as_ref().is_some_and(|window| { - let size = window.inner_size(); - size.width != 0 && size.height != 0 - }); - if drawable_available { - if let Err(error) = self.apply_pending_scene_update() { - self.error = Some(error); - event_loop.exit(); - return; - } + if let Err(error) = self.render_scene_update() { + self.error = Some(error); + event_loop.exit(); + return; } + let diagnostic_clock_ms = self + .pending_scene_update + .as_ref() + .map_or(self.simulation_time_ms, |frame| frame.clock.time_ms); let outcome = { let Some(renderer) = self.renderer.as_mut() else { self.error = Some("native Vulkan renderer was not initialized".to_string()); @@ -6765,28 +10711,50 @@ impl ApplicationHandler for StaticVulkanApp { }; match outcome { Ok(VulkanSmokeFrameOutcome::Presented) => { - self.lifecycle.record_presented(); - } - Ok(VulkanSmokeFrameOutcome::Recreated) => { - if let Some((width, height)) = self - .renderer - .as_ref() - .map(|renderer| renderer.report().swapchain_extent) - { - if width != 0 && height != 0 { - if let Err(error) = - self.refresh_pending_viewport([width as f32, height as f32]) - { - self.error = Some(error); + let frame_index = self.record_presented_scene_frame(); + if let Some(directory) = self.readback_sequence_out.as_deref() { + let artifact = match self + .renderer + .as_mut() + .expect("renderer presented a frame") + .readback_last_submitted_frame() + { + Ok(Some(artifact)) => artifact, + Ok(None) => { + self.error = Some( + "native Vulkan renderer produced no synchronized sequence readback" + .to_string(), + ); + event_loop.exit(); + return; + } + Err(error) => { + self.error = + Some(format!("read back frame {frame_index}: {error}")); event_loop.exit(); return; } + }; + if let Err(error) = write_readback_sequence_frame( + directory, + frame_index, + diagnostic_clock_ms, + &artifact, + ) { + self.error = Some(error); + event_loop.exit(); + return; } } } + Ok(VulkanSmokeFrameOutcome::Recreated) => {} Ok(VulkanSmokeFrameOutcome::ZeroExtent) => { - if !drawable_available { + if self.window.as_ref().is_some_and(|window| { + let size = window.inner_size(); + size.width == 0 || size.height == 0 + }) { event_loop.set_control_flow(ControlFlow::Wait); + self.pause_live_clock_at(Instant::now()); } } Err(err) => { @@ -6795,11 +10763,8 @@ impl ApplicationHandler for StaticVulkanApp { return; } } - if self.target_frames != 0 && self.lifecycle.frames_presented >= self.target_frames - { + if self.target_frames != 0 && self.frames_presented >= self.target_frames { self.complete(event_loop); - } else if drawable_available { - self.schedule_next_redraw(); } } _ => {} @@ -6815,28 +10780,21 @@ impl ApplicationHandler for StaticVulkanApp { }; let size = window.inner_size(); if size.width == 0 || size.height == 0 { + // Wait for a resize instead of spinning simulation updates while + // the window has no drawable surface. event_loop.set_control_flow(ControlFlow::Wait); + self.pause_live_clock_at(Instant::now()); return; } event_loop.set_control_flow(ControlFlow::Poll); - if self.lifecycle.pending.is_none() { - let viewport = self - .captured_viewport - .map_or([size.width as f32, size.height as f32], |captured| { - [captured.extent[0] as f32, captured.extent[1] as f32] - }); - let sample = self.sample_frame_clock(); - let update = match self.prepare_scene_update(sample, viewport) { - Ok(update) => update, - Err(error) => { - self.error = Some(error); - event_loop.exit(); - return; - } - }; - if !self.lifecycle.prepare(update) { - return; - } + self.resume_live_clock_at(Instant::now()); + let clock = self.pending_or_sample_frame_clock(); + if let Err(error) = + self.prepare_scene_update(clock, [size.width as f32, size.height as f32]) + { + self.error = Some(error); + event_loop.exit(); + return; } self.schedule_next_redraw(); } @@ -6851,6 +10809,7 @@ struct Args { control_clock_ms: Option<u32>, validation: bool, readback_out: Option<PathBuf>, + readback_sequence_out: Option<PathBuf>, preview_roots: NonZeroUsize, legacy_camera_capture: Option<PathBuf>, static_animation_frame: Option<u16>, @@ -6869,6 +10828,7 @@ impl Args { let mut control_clock_ms = None; let mut validation = false; let mut readback_out = None; + let mut readback_sequence_out = None; // A native static-Vulkan invocation is the usable mission preview, so // its default scope covers every root. `PreviewRoots` is clamped by the // runtime to the decoded mission length; an explicit --preview-roots N @@ -6932,6 +10892,12 @@ impl Args { .ok_or_else(|| "--readback-out requires a path".to_string())?, ); } + "--readback-sequence-out" => { + readback_sequence_out = + Some(iter.next().map(PathBuf::from).ok_or_else(|| { + "--readback-sequence-out requires a path".to_string() + })?); + } "--preview-roots" => { preview_roots = iter .next() @@ -6976,6 +10942,12 @@ impl Args { .to_string(), ); } + if readback_sequence_out.is_some() && control_clock_ms.is_none() { + return Err( + "--readback-sequence-out requires --control-clock-ms for a reproducible sequence" + .to_string(), + ); + } Ok(Self { root, mission, @@ -6984,6 +10956,7 @@ impl Args { control_clock_ms, validation, readback_out, + readback_sequence_out, preview_roots, legacy_camera_capture, static_animation_frame, @@ -7150,205 +11123,367 @@ fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> { } fn usage() -> String { - "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--control-clock-ms <u32>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally. --control-clock-ms replays one shared MSH/material/environment clock from zero to the selected time over --frames frames.".to_string() + "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--control-clock-ms <u32>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>] [--readback-sequence-out <directory>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally. --control-clock-ms replays one shared MSH/FX clock from zero to the selected time over the requested frames; --readback-sequence-out captures each submitted frame and requires that clock.".to_string() } #[cfg(test)] mod tests { use super::*; + use fparkan_animation::{AnimKey24, Pose}; + use fparkan_assets::{PreparedControlTimelineRow, PreparedControlTypedRecord}; use fparkan_fx::environment::{WeatherInput, WeatherState}; - use fparkan_material::{MaterialAnimationBlock, MaterialKey}; + use fparkan_msh::{Batch, ModelAnimation, Slot}; + use fparkan_path::{normalize_relative, PathPolicy}; + use fparkan_resource::{resource_name, ResourceKey}; - fn material_test_phase(parameters: [u8; 18]) -> MaterialPhase { - MaterialPhase { - parameters, - texture_raw: [0; 16], - } + fn facing_mesh( + batch_flags: u16, + positions: &[[f32; 3]], + ) -> (VulkanStaticMesh, VulkanStaticDrawRange) { + let range = VulkanStaticDrawRange { + first_index: 0, + index_count: u32::try_from(positions.len()).expect("fixture length fits u32"), + material_index: 0, + lightmap_index: 0, + batch_flags, + pipeline_state: LegacyPipelineState::default(), + terrain_base_only: false, + alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, + }; + let mesh = VulkanStaticMesh { + vertices: positions + .iter() + .copied() + .map(|position| VulkanStaticVertex { + position, + color: [1.0; 3], + normal: [0.0, 0.0, 1.0], + uv: [0.0; 2], + detail_uv: [0.0; 2], + overlay_alpha: 0.0, + }) + .collect(), + indices: (0..positions.len()) + .map(|index| u32::try_from(index).expect("fixture index fits u32")) + .collect(), + draw_ranges: vec![range], + }; + (mesh, range) } - fn random_material_binding(range_index: usize) -> MaterialAnimationBinding { - let mut overlay_phase = [0; 18]; - overlay_phase[..3].copy_from_slice(&[100, 50, 25]); - overlay_phase[3] = 25; - overlay_phase[4..7].copy_from_slice(&[30, 60, 90]); - overlay_phase[8..11].copy_from_slice(&[40, 80, 120]); - overlay_phase[16] = 5; - let mut active_phase = [0; 18]; - active_phase[..3].copy_from_slice(&[25, 50, 75]); - active_phase[3] = 75; - active_phase[4..7].copy_from_slice(&[64, 128, 192]); - active_phase[8..11].copy_from_slice(&[100, 150, 200]); - active_phase[16] = 9; - active_phase[17] = 1; - let document = Mat0Document { - version: 0, - animation_block_count: 1, - phases: vec![ - material_test_phase(overlay_phase), - material_test_phase(active_phase), + fn native_front_triangle() -> [[f32; 3]; 3] { + [[-0.2, -0.2, -1.0], [0.2, -0.2, -1.0], [0.0, 0.2, -1.0]] + } + + fn camera_face_triangle(camera: PreviewCameraFrame, mirror: bool) -> [[f32; 3]; 3] { + let center = add3(camera.position, scale3(camera.forward, 10.0)); + let mirror = if mirror { -1.0 } else { 1.0 }; + [ + add3( + add3(center, scale3(camera.right, -2.0 * mirror)), + scale3(camera.up, -2.0), + ), + add3( + add3(center, scale3(camera.right, 2.0 * mirror)), + scale3(camera.up, -2.0), + ), + add3(center, scale3(camera.up, 2.0)), + ] + } + + fn projected_vulkan_area(camera: PreviewCameraFrame, triangle: [[f32; 3]; 3]) -> f32 { + let screen = triangle.map(|point| { + let clip = + row_vector_transform([point[0], point[1], point[2], 1.0], camera.clip_from_world); + assert!(clip[3] > 0.0 && clip.iter().all(|value| value.is_finite())); + // This is the positive-height viewport transform used by the Vulkan renderer. + [ + (clip[0] / clip[3] + 1.0) * 0.5, + (clip[1] / clip[3] + 1.0) * 0.5, + ] + }); + let shoelace = (0..3) + .map(|index| { + let next = (index + 1) % 3; + screen[index][0] * screen[next][1] - screen[next][0] * screen[index][1] + }) + .sum::<f32>() + * 0.5; + // Vulkan's framebuffer signed-area definition is the negative shoelace area. + -shoelace + } + + #[test] + fn native_msh_cull_mode_matches_legacy_and_free_flight_camera_projection() { + let identity = RawCameraTransform { + words: [ + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), ], - prefix: Vec::new(), - header_opaque: [0; 2], - animation_blocks: vec![MaterialAnimationBlock { - header_raw: 3, - keys: vec![ - MaterialKey { - k0: 0, - k1: 20, - k2: 0, - }, - MaterialKey { - k0: 1, - k1: 40, - k2: 0, - }, - ], - bytes: Vec::new(), - }], }; - let frozen_binding = |phase_index: usize, uv_transform: [f32; 4]| MaterialPhaseBinding { - document: document.clone(), - animation_block_index: 0, - wear_row_start_ms: 0, - random_state: 0, - frozen_phase_index: Some(phase_index), - phase_uv_transforms: vec![uv_transform; 2], + let projection = LegacyD3d7Projection { + viewport: [0, 0, 1280, 1024], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: 1.3, }; - let texture = solid_environment_texture([255; 4]); - MaterialAnimationBinding { - initial_phase_index: 0, - variants: vec![MaterialVariantSet { - phase_material_indices: vec![4, 5], - range_indices: vec![range_index], - }], - base: MaterialPhaseBinding { - document: document.clone(), - animation_block_index: 0, - wear_row_start_ms: 0, - random_state: 0, - frozen_phase_index: None, - phase_uv_transforms: vec![[0.0, 0.0, 0.5, 0.5], [0.5, 0.25, 0.25, 0.5]], - }, - detail: Some(frozen_binding(1, [0.1, 0.2, 0.3, 0.4])), - overlay: Some(frozen_binding(0, [0.3, 0.4, 0.5, 0.6])), - overlay_detail: Some(frozen_binding(1, [0.6, 0.7, 0.8, 0.9])), - stage_textures: Some(MaterialStageTextures { - detail: Some(vec![texture.clone(), texture.clone()]), - overlay: Some(vec![texture.clone()]), - overlay_detail: Some(vec![texture.clone(), texture]), - applied_material_phases: HashMap::from([(5, [1, 0, 1])]), - }), + let legacy = PreviewCameraFrame::from_legacy_d3d7(identity, projection) + .expect("valid legacy camera projection"); + let free_flight = FreeFlightCamera { + position: legacy.position, + yaw: 0.0, + pitch: 0.0, + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: 1.0, + ground_clearance: 0.0, } - } + .preview_frame(1280.0 / 1024.0); - fn packet_test_materials() -> Vec<VulkanStaticMaterial> { - let mut materials = Vec::new(); - for _ in 0..6 { - append_environment_gradient_material(&mut materials).expect("test material"); + for camera in [legacy, free_flight] { + let front = camera_face_triangle(camera, false); + let back = [front[0], front[2], front[1]]; + let front_d = dot3( + sub3(camera.position, front[0]), + cross3(sub3(front[1], front[0]), sub3(front[2], front[0])), + ); + let back_d = dot3( + sub3(camera.position, back[0]), + cross3(sub3(back[1], back[0]), sub3(back[2], back[0])), + ); + assert!(front_d > 0.0); + assert!(back_d < 0.0); + + let front_vk_area = projected_vulkan_area(camera, front); + let back_vk_area = projected_vulkan_area(camera, back); + // Vulkan CLOCKWISE front faces have negative framebuffer area. + // The native-visible d>0 face is therefore a Vulkan back face. + assert!(front_vk_area > 0.0, "front area: {front_vk_area}"); + assert!(back_vk_area < 0.0, "back area: {back_vk_area}"); + assert_eq!(native_msh_cull_mode(0, 1), LegacyCullMode::FrontFace); + + let mirrored_front = camera_face_triangle(camera, true); + let mirrored_d = dot3( + sub3(camera.position, mirrored_front[0]), + cross3( + sub3(mirrored_front[1], mirrored_front[0]), + sub3(mirrored_front[2], mirrored_front[0]), + ), + ); + assert!(mirrored_d < 0.0); + assert!(projected_vulkan_area(camera, mirrored_front) < 0.0); + assert_eq!(native_msh_cull_mode(0, -1), LegacyCullMode::BackFace); } - materials[4].pipeline_category = 1; - materials[5].pipeline_category = 3; - materials } #[test] - fn sampled_material_packet_keeps_native_selector_properties_and_stage_phases() { - let materials = packet_test_materials(); - let mut bindings = vec![random_material_binding(7)]; - let packet = sample_material_animations(&mut bindings, &materials, 500) - .expect("sample random MAT0 state"); + fn native_msh_handedness_uses_full_placement_transform_and_handles_singular_scale() { + let rotated = LegacyIron3dEulerTransform { + translation: [8.0, -4.0, 3.0], + orientation_radians: [0.4, -0.7, 1.2], + }; + assert_eq!(native_msh_handedness(rotated, [2.0, 3.0, 4.0]), 1); + assert_eq!(native_msh_handedness(rotated, [-2.0, 3.0, 4.0]), -1); + assert_eq!(native_msh_handedness(rotated, [0.0, 3.0, 4.0]), 0); + assert_eq!(native_msh_cull_mode(0, 0), LegacyCullMode::Disabled); + } + + #[test] + fn native_msh_4000_samples_only_first_triangle_and_raw_two_sided_bypasses_it() { + let front = native_front_triangle(); + let back = front.map(|point| [-point[0], point[1], point[2]]); + let front_then_back = [front[0], front[1], front[2], back[0], back[1], back[2]]; + let (mesh, range) = facing_mesh(0x4000, &front_then_back); + assert!(native_msh_first_triangle_visible( + &mesh, &range, [0.0; 3], 1 + )); + let back_then_front = [back[0], back[1], back[2], front[0], front[1], front[2]]; + let (mesh, range) = facing_mesh(0x4000, &back_then_front); + assert!(!native_msh_first_triangle_visible( + &mesh, &range, [0.0; 3], 1 + )); + + let (mesh, range) = facing_mesh(0x4002, &back_then_front); + assert!(native_msh_first_triangle_visible( + &mesh, &range, [0.0; 3], 1 + )); assert_eq!( - packet.range_selections, - vec![MaterialRangeSelection { - range_index: 7, - material_index: 5, - pipeline_category: 3, - }] - ); - assert_eq!( - packet.active_materials.get(&7), - Some(&ActiveMaterialState { - material_index: 5, - diffuse_alpha: 0.75, - }) - ); - let properties = packet.properties[0]; - assert_eq!( - (properties.diffuse_alpha, properties.overlay_alpha), - (0.75, 0.25) + native_msh_cull_mode(range.batch_flags, 1), + LegacyCullMode::Disabled ); + } + + #[test] + fn world_draw_range_count_restores_after_alpha_and_msh_facing_changes() { + let triangle = native_front_triangle(); + let (mesh, range) = facing_mesh(0x4000, &triangle); + let original_count = range.index_count; + assert_eq!( - (properties.base_directional, properties.base_additive), - ( - [64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0], - [25.0 / 255.0, 50.0 / 255.0, 75.0 / 255.0], - ) + world_draw_range_index_count(&mesh, &range, original_count, 0.0, [0.0; 3], Some(1)), + 0 ); assert_eq!( - (properties.overlay_directional, properties.overlay_additive), - ( - Some([30.0 / 255.0, 60.0 / 255.0, 90.0 / 255.0]), - Some([100.0 / 255.0, 50.0 / 255.0, 25.0 / 255.0]), - ) + world_draw_range_index_count( + &mesh, + &range, + original_count, + 1.0, + [0.0, 0.0, -2.0], + Some(1), + ), + 0 ); assert_eq!( - (properties.specular_rgb, properties.specular_power), - ([100.0 / 255.0, 150.0 / 255.0, 200.0 / 255.0], 9) + world_draw_range_index_count(&mesh, &range, original_count, 1.0, [0.0; 3], Some(1)), + original_count ); assert_eq!( - (properties.overlay_specular_rgb, properties.overlay_power), - (Some([40.0 / 255.0, 80.0 / 255.0, 120.0 / 255.0]), Some(5)) + world_draw_range_index_count( + &mesh, + &range, + original_count, + 1.0, + [0.0, 0.0, -2.0], + None, + ), + original_count ); + } + + #[test] + fn world_draw_range_count_clears_and_reapplies_constant_alpha_override() { + let triangle = native_front_triangle(); + let (mesh, mut range) = facing_mesh(0x0148, &triangle); + let original_count = range.index_count; + let mut alpha_override = None; + let update = |range: &VulkanStaticDrawRange, + eye_world: [f32; 3], + alpha_override: &mut Option<f32>| { + let mut current_mesh = mesh.clone(); + current_mesh.draw_ranges[0] = *range; + *alpha_override = + native_msh_diffuse_alpha_override(¤t_mesh, 0, false, eye_world, 1.3).unwrap(); + world_draw_range_index_count( + &mesh, + range, + original_count, + alpha_override.unwrap_or(1.0), + eye_world, + None, + ) + }; + + assert_eq!(update(&range, [0.0; 3], &mut alpha_override), 0); + range.batch_flags = 0x0108; + assert_eq!(update(&range, [0.0; 3], &mut alpha_override), 0); assert_eq!( - properties.uv_transforms, - [ - [0.5, 0.25, 0.25, 0.5], - [0.1, 0.2, 0.3, 0.4], - [0.3, 0.4, 0.5, 0.6], - [0.6, 0.7, 0.8, 0.9], - ] + update(&range, [150.0; 3], &mut alpha_override), + original_count ); - assert_eq!( - packet.stage_phases, - vec![MaterialStagePhaseUpdate { - binding_index: 0, - material_index: 5, - phases: [1, 0, 1], - }] - ); - assert_eq!(bindings[0].base.random_state, 2_531_011); - } - - fn control_pose_test_model() -> ModelAsset { - let mut nodes = vec![u8::MAX; 38]; - nodes[..2].copy_from_slice(&[0, 0]); - nodes[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); - nodes[4..6].copy_from_slice(&0_u16.to_le_bytes()); - nodes[6..8].copy_from_slice(&1_u16.to_le_bytes()); + range.batch_flags = 0x0148; + assert_eq!(update(&range, [150.0; 3], &mut alpha_override), 0); + assert_eq!(original_count, 3); + } + + fn identity_matrix() -> [[f32; 4]; 4] { + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + } + + fn node38_fixture(parent: u16, map_start: u16, fallback: u16, drawable: bool) -> Vec<u8> { + let mut node = Vec::with_capacity(38); + node.extend_from_slice(&[0, 0]); + node.extend_from_slice(&parent.to_le_bytes()); + node.extend_from_slice(&map_start.to_le_bytes()); + node.extend_from_slice(&fallback.to_le_bytes()); + for slot in 0..15 { + let value = if drawable && slot == 0 { 0 } else { u16::MAX }; + node.extend_from_slice(&value.to_le_bytes()); + } + node + } + + fn animated_socket_model() -> ModelAsset { + let mut nodes = node38_fixture(u16::MAX, 0, 1, true); + nodes.extend(node38_fixture(0, u16::MAX, 2, false)); ModelAsset { node_stride: 38, - node_count: 1, + node_count: 2, nodes_raw: nodes, bounding_sphere_center: [0.0; 3], - bounding_sphere_radius: 1.0, - slots: vec![], - positions: vec![], - normals: None, + bounding_sphere_radius: 2.0, + slots: vec![Slot { + tri_start: 0, + tri_count: 1, + batch_start: 0, + batch_count: 1, + aabb_min: [-1.0; 3], + aabb_max: [1.0; 3], + sphere_center: [0.0; 3], + sphere_radius: 1.0, + opaque: [0; 5], + }], + positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + normals: Some(vec![[127, 0, 0, 0]; 3]), uv0: None, uv1: None, - indices: vec![], - batches: vec![], + type16_present: false, + indices: vec![0, 1, 2], + batches: vec![Batch { + batch_flags: 0, + material_index_hi: 0, + material_index: 0, + lightmap_index: 0, + local_batch_index: 0, + index_count: 3, + index_start: 0, + vertex_count: 3, + base_vertex: 0, + }], + triangle_descriptors: None, node_names: None, - animation: Some(fparkan_msh::ModelAnimation { + animation: Some(ModelAnimation { keys: vec![ - fparkan_animation::AnimKey24 { + AnimKey24 { time: AnimationTime(0.0), - pose: fparkan_animation::Pose::default(), + pose: Pose::default(), }, - fparkan_animation::AnimKey24 { + AnimKey24 { time: AnimationTime(1.0), - pose: fparkan_animation::Pose { + pose: Pose { + translation: [0.0; 3], + rotation: [ + 0.0, + 0.0, + std::f32::consts::FRAC_1_SQRT_2, + std::f32::consts::FRAC_1_SQRT_2, + ], + }, + }, + AnimKey24 { + time: AnimationTime(0.0), + pose: Pose { translation: [0.0, 2.0, 0.0], rotation: [0.0, 0.0, 0.0, 1.0], }, @@ -7360,59 +11495,785 @@ mod tests { } } - fn control_pose_drawable_test_model() -> ModelAsset { - let mut model = control_pose_test_model(); - model.nodes_raw[8..10].copy_from_slice(&0_u16.to_le_bytes()); - model.slots = vec![fparkan_msh::Slot { - tri_start: 0, - tri_count: 1, - batch_start: 0, - batch_count: 1, - aabb_min: [0.0; 3], - aabb_max: [1.0; 3], - sphere_center: [0.0; 3], - sphere_radius: 1.0, - opaque: [0; 5], - }]; - model.positions = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; - model.normals = Some(vec![[0, 0, 127, 0]; 3]); - model.uv0 = Some(vec![[0, 0]; 3]); - model.uv1 = None; - model.indices = vec![0, 1, 2]; - model.batches = vec![fparkan_msh::Batch { - batch_flags: 0, - material_index_hi: 0, - material_index: 0, - lightmap_index: u8::MAX, - local_batch_index: 0, - index_count: 3, - index_start: 0, - vertex_count: 3, - base_vertex: 0, - }]; - model + fn static_child_model() -> ModelAsset { + let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, false); + nodes.extend(node38_fixture(0, u16::MAX, 0, true)); + ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 1.0, + slots: vec![Slot { + tri_start: 0, + tri_count: 1, + batch_start: 0, + batch_count: 1, + aabb_min: [-1.0; 3], + aabb_max: [1.0; 3], + sphere_center: [0.0; 3], + sphere_radius: 1.0, + opaque: [0; 5], + }], + positions: vec![[0.0, 0.0, 0.0]; 3], + normals: Some(vec![[0, 0, 127, 0]; 3]), + uv0: None, + uv1: None, + type16_present: false, + indices: vec![0, 1, 2], + batches: vec![Batch { + batch_flags: 0, + material_index_hi: 0, + material_index: 0, + lightmap_index: 0, + local_batch_index: 0, + index_count: 3, + index_start: 0, + vertex_count: 3, + base_vertex: 0, + }], + triangle_descriptors: None, + node_names: None, + animation: Some(ModelAnimation { + keys: vec![AnimKey24 { + time: AnimationTime(0.0), + pose: Pose::default(), + }], + frame_map: vec![], + frame_count: 0, + }), + } + } + + fn collision_fixture_model(root_drawable: bool) -> ModelAsset { + let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, root_drawable); + nodes.extend(node38_fixture(0, 0, 1, true)); + ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 3.0, + slots: vec![Slot { + tri_start: 0, + tri_count: 1, + batch_start: 0, + batch_count: 1, + aabb_min: [-2.0, 0.0, -2.0], + aabb_max: [2.0, 0.0, 2.0], + sphere_center: [0.0; 3], + sphere_radius: 2.0, + opaque: [0; 5], + }], + positions: vec![[-2.0, 0.0, -2.0], [0.0, 0.0, 2.0], [2.0, 0.0, -2.0]], + normals: Some(vec![[0, 127, 0, 0]; 3]), + uv0: None, + uv1: None, + type16_present: false, + indices: vec![0, 1, 2], + batches: vec![Batch { + batch_flags: 0, + material_index_hi: 0, + material_index: 0, + lightmap_index: 0, + local_batch_index: 0, + index_count: 3, + index_start: 0, + vertex_count: 3, + base_vertex: 0, + }], + triangle_descriptors: Some(vec![fparkan_msh::TriangleDescriptor { + tri_flags: 0, + opaque: [0; 4], + normal: [0, 32767, 0], + }]), + node_names: None, + animation: Some(ModelAnimation { + keys: vec![ + AnimKey24 { + time: AnimationTime(0.0), + pose: Pose::default(), + }, + AnimKey24 { + time: AnimationTime(1.0), + pose: Pose { + translation: [20.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + ], + frame_map: vec![0, 1], + frame_count: 2, + }), + } + } + + #[test] + fn mounted_static_building_binding_matches_renderer_and_hides_root_node() { + let model = collision_fixture_model(true); + let binding = building_collision_binding( + 4, + 5, + "static fixture model".to_string(), + Arc::new(model.clone()), + LegacyIron3dEulerTransform { + translation: [13.0, -4.0, 9.0], + orientation_radians: [0.4, -0.3, 0.7], + }, + [2.0, 3.0, 0.5], + true, + ) + .expect("static building geometry still creates a collision binding"); + assert_eq!( + binding.selected_nodes, + vec![(fparkan_msh::NodeId(1), fparkan_msh::SlotId(0))] + ); + + let root = VulkanNodePose { + translation: [1.0, 2.0, 3.0], + rotation: [0.0, 0.0, 0.29552022, 0.9553365], + }; + let moving_node = compose_unit_pose( + root, + VulkanNodePose { + translation: [2.0, -1.0, 0.5], + rotation: [0.0, 0.19866933, 0.0, 0.9800666], + }, + ); + let hierarchy = NodePoseBuffer { + poses: [root, moving_node] + .into_iter() + .map(|pose| Pose { + translation: pose.translation, + rotation: pose.rotation, + }) + .collect(), + }; + let mount = VulkanNodePose { + translation: [5.0, 7.0, 11.0], + rotation: [0.0, 0.0, 0.38268343, 0.9238795], + }; + let nodes = building_collision_nodes(&binding, Some(&hierarchy), Some(mount)) + .expect("mounted collision matrices"); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].node, fparkan_msh::NodeId(1)); + + let projected = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( + &model, + binding.root_transform, + binding.owner_scale, + &hierarchy, + mount, + ) + .expect("mounted renderer projection"); + assert_eq!(projected.vertices.len(), 3); + for (actual, source) in projected.vertices.iter().zip(&model.positions) { + let matrix = nodes[0].world_from_node; + let expected = [ + matrix[0] * source[0] + matrix[1] * source[1] + matrix[2] * source[2] + matrix[3], + matrix[4] * source[0] + matrix[5] * source[1] + matrix[6] * source[2] + matrix[7], + matrix[8] * source[0] + matrix[9] * source[1] + matrix[10] * source[2] + matrix[11], + ]; + for axis in 0..3 { + assert!((actual.position[axis] - expected[axis]).abs() < 1.0e-4); + } + } + } + + fn doorway_collision_model() -> ModelAsset { + let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, false); + nodes.extend(node38_fixture(0, u16::MAX, 0, true)); + ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 5.0, + slots: vec![Slot { + tri_start: 0, + tri_count: 4, + batch_start: 0, + batch_count: 1, + aabb_min: [-4.0, 0.0, -2.0], + aabb_max: [4.0, 0.0, 2.0], + sphere_center: [0.0; 3], + sphere_radius: 5.0, + opaque: [0; 5], + }], + positions: vec![ + [-4.0, 0.0, -2.0], + [-1.0, 0.0, -2.0], + [-1.0, 0.0, 2.0], + [-4.0, 0.0, 2.0], + [1.0, 0.0, -2.0], + [4.0, 0.0, -2.0], + [4.0, 0.0, 2.0], + [1.0, 0.0, 2.0], + ], + normals: None, + uv0: None, + uv1: None, + type16_present: false, + indices: vec![0, 3, 2, 0, 2, 1, 4, 7, 6, 4, 6, 5], + batches: vec![Batch { + batch_flags: 0, + material_index_hi: 0, + material_index: 0, + lightmap_index: 0, + local_batch_index: 0, + index_count: 12, + index_start: 0, + vertex_count: 8, + base_vertex: 0, + }], + triangle_descriptors: Some( + (0..4) + .map(|_| fparkan_msh::TriangleDescriptor { + tri_flags: 0, + opaque: [0; 4], + normal: [0, 32767, 0], + }) + .collect(), + ), + node_names: None, + animation: Some(ModelAnimation { + keys: vec![AnimKey24 { + time: AnimationTime(0.0), + pose: Pose::default(), + }], + frame_map: Vec::new(), + frame_count: 0, + }), + } + } + + fn collision_test_app( + model: Arc<ModelAsset>, + root_transform: LegacyIron3dEulerTransform, + position: [f32; 3], + ) -> StaticVulkanApp { + let (mut app, _) = clock_test_app(None); + let hierarchy = static_preview_component_pose_buffer(&model, &[], None) + .expect("fixture static preview pose") + .expect("fixture Node38 pose"); + app.component_pose_buffers = vec![vec![Some(hierarchy)]]; + app.component_mount_poses = vec![vec![None]]; + app.dynamic_component_geometry = vec![vec![None]]; + app.control_animation_instances = vec![vec![None]]; + app.building_collision_bindings = vec![building_collision_binding( + 0, + 0, + "collision fixture".to_string(), + Arc::clone(&model), + root_transform, + [1.0; 3], + false, + ) + .expect("fixture building collision binding")]; + app.free_camera = Some(FreeFlightCamera { + position, + yaw: -std::f32::consts::FRAC_PI_2, + pitch: 0.0, + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: 60.0, + ground_clearance: 0.0, + }); + app.pressed_keys.insert(KeyCode::KeyW); + app + } + + #[test] + fn high_speed_free_flight_stops_at_static_building_wall() { + let mut app = collision_test_app( + Arc::new(collision_fixture_model(false)), + LegacyIron3dEulerTransform { + translation: [0.0, 5.0, 0.0], + orientation_radians: [0.0; 3], + }, + [0.0, 8.0, 0.0], + ); + app.update_free_camera(0.1, [1280.0, 720.0]) + .expect("continuous building query"); + assert_eq!(app.free_camera.unwrap().position, [0.0, 8.0, 0.0]); + } + + #[test] + fn small_nonzero_building_scale_remains_queryable() { + let model = Arc::new(collision_fixture_model(false)); + let binding = building_collision_binding( + 0, + 0, + "tiny-scale fixture".to_string(), + Arc::clone(&model), + LegacyIron3dEulerTransform { + translation: [0.0, 5.0, 0.0], + orientation_radians: [0.0; 3], + }, + [1.0e-8, 1.0, 1.0], + false, + ) + .expect("small nonzero scale is invertible"); + let hierarchy = static_preview_component_pose_buffer(&model, &[], None) + .expect("fixture static preview pose") + .expect("fixture Node38 pose"); + let nodes = building_collision_nodes(&binding, Some(&hierarchy), None) + .expect("small-scale collision matrix"); + assert!(query_building_segment_world( + &binding.model, + &nodes, + [[0.0, 8.0, 0.0], [0.0, 2.0, 0.0]], + ) + .expect("small-scale query") + .is_some()); + } + + #[test] + fn free_flight_passes_through_authored_doorway() { + let mut app = collision_test_app( + Arc::new(doorway_collision_model()), + LegacyIron3dEulerTransform { + translation: [0.0, 5.0, 0.0], + orientation_radians: [0.0; 3], + }, + [0.0, 8.0, 0.0], + ); + app.update_free_camera(0.1, [1280.0, 720.0]) + .expect("doorway query"); + let position = app.free_camera.unwrap().position; + assert!((position[1] - 2.0).abs() < 1.0e-4); + } + + #[test] + fn shared_building_models_keep_each_placed_instance_query_separate() { + let model = Arc::new(collision_fixture_model(false)); + let far = building_collision_binding( + 0, + 0, + "shared fixture".to_string(), + Arc::clone(&model), + LegacyIron3dEulerTransform { + translation: [0.0, 100.0, 0.0], + orientation_radians: [0.0; 3], + }, + [1.0; 3], + false, + ) + .expect("first placed building"); + let near = building_collision_binding( + 1, + 0, + "shared fixture".to_string(), + Arc::clone(&model), + LegacyIron3dEulerTransform { + translation: [0.0, 5.0, 0.0], + orientation_radians: [0.0; 3], + }, + [1.0; 3], + false, + ) + .expect("second placed building"); + assert!(Arc::ptr_eq(&far.model, &near.model)); + + let (mut app, _) = clock_test_app(None); + let hierarchy = static_preview_component_pose_buffer(&model, &[], None) + .expect("shared fixture static pose") + .expect("shared fixture Node38 pose"); + app.component_pose_buffers = vec![vec![Some(hierarchy.clone())], vec![Some(hierarchy)]]; + app.component_mount_poses = vec![vec![None], vec![None]]; + app.building_collision_bindings = vec![far, near]; + app.free_camera.as_mut().unwrap().position = [0.0, 8.0, 0.0]; + app.free_camera.as_mut().unwrap().yaw = -std::f32::consts::FRAC_PI_2; + app.free_camera.as_mut().unwrap().pitch = 0.0; + app.free_camera.as_mut().unwrap().move_speed = 60.0; + app.pressed_keys.insert(KeyCode::KeyW); + app.update_free_camera(0.1, [1280.0, 720.0]) + .expect("both placed buildings are queried independently"); + assert_eq!(app.free_camera.unwrap().position, [0.0, 8.0, 0.0]); + } + + #[test] + fn collision_uses_updated_control_pose_and_retries_do_not_move_again() { + let model = Arc::new(collision_fixture_model(false)); + let mut app = collision_test_app( + Arc::clone(&model), + LegacyIron3dEulerTransform { + translation: [0.0, 5.0, 0.0], + orientation_radians: [0.0; 3], + }, + [0.0, 8.0, 0.0], + ); + let mut control = socket_timeline_control(); + control.timeline_rows[0].node_index = 1; + app.control_animation_instances[0][0] = Some(ControlAnimationInstance { + control, + model: (*model).clone(), + gate_values: vec![Some(0.0)], + state: ControlInstanceState::default(), + last_sampled_node_times: Vec::new(), + reported_unsupported_fallback: false, + }); + + let first = FrameClockSample { + id: SceneSampleId::Replay(0), + time_ms: 0, + delta_seconds: 0.1, + }; + app.prepare_scene_update(first, [1280.0, 720.0]) + .expect("closed moving wall blocks the first frame"); + let safe_position = app.free_camera.unwrap().position; + let closed_pose = app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation; + assert_eq!(safe_position, [0.0, 8.0, 0.0]); + + app.control_animation_instances[0][0] + .as_mut() + .unwrap() + .gate_values[0] = Some(1.0); + app.prepare_scene_update(first, [1024.0, 768.0]) + .expect("pending retry only refreshes its viewport"); + assert_eq!(app.free_camera.unwrap().position, safe_position); + assert_eq!( + app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation, + closed_pose + ); + + app.prepare_scene_update( + FrameClockSample { + id: SceneSampleId::Replay(1), + time_ms: 1000, + delta_seconds: 0.1, + }, + [1024.0, 768.0], + ) + .expect("the next sampled pose opens the path"); + let open_pose = app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation; + assert!(open_pose[0] > 2.0, "door node did not move: {open_pose:?}"); + assert!((app.free_camera.unwrap().position[1] - 2.0).abs() < 1.0e-4); + } + + #[test] + fn missing_free_camera_keeps_legacy_view_without_querying_buildings() { + let (mut app, _) = clock_test_app(None); + app.free_camera = None; + app.building_collision_bindings + .push(BuildingCollisionBinding { + object_index: 0, + component_index: 0, + model_source: "legacy fixture".to_string(), + model: Arc::new(collision_fixture_model(false)), + root_transform: LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0; 3], + }, + owner_scale: [1.0; 3], + mounted: false, + selected_nodes: vec![(NodeId(u32::MAX), SlotId(u32::MAX))], + }); + let camera = app.camera; + let view_camera = app.view_camera; + app.update_free_camera(0.1, [1024.0, 768.0]) + .expect("legacy camera skips free-flight collision"); + assert_eq!(app.camera, camera); + assert_eq!(app.view_camera, view_camera); } - fn control_pose_test_control() -> PreparedComponentControl { + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_plant_wall_and_door_use_the_app_camera_bridge() { + use std::path::PathBuf; + + const BODY_WALL: [[f32; 3]; 2] = [[-10.5, 22.16, 2.5], [-10.5, 19.16, 2.5]]; + const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]]; + let root = PathBuf::from( + std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed tests"), + ); + let mission = "MISSIONS/Autodemo.00/data.tma"; + let args = Args::parse(&[ + "--root".to_string(), + root.to_string_lossy().into_owned(), + "--mission".to_string(), + mission.to_string(), + ]) + .expect("licensed AutoDemo app arguments"); + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(&args.root)))) + .expect("create licensed AutoDemo engine"); + let loaded = load_requested_mission(&mut engine, &args).expect("load AutoDemo mission"); + let assets = loaded_mission_assets(&engine).expect("AutoDemo assets"); + let terrain = loaded_terrain(&engine).expect("AutoDemo terrain"); + let roots = loaded_mission_object_drafts(&engine).expect("AutoDemo object drafts"); + let preview = static_preview_mesh_and_materials( + assets, + terrain, + roots, + None, + None, + None, + &args.root, + &loaded.land_msh_path, + ) + .expect("build AutoDemo static preview"); + let mut component_bindings = preview + .building_collision_bindings + .iter() + .map(|binding| (binding.object_index, binding.component_index)) + .collect::<Vec<_>>(); + component_bindings.sort_unstable(); + assert_eq!( + component_bindings, + vec![(4, 0), (4, 5), (5, 0), (5, 5), (6, 0), (7, 0)] + ); + + let shadow_range = EnvironmentGpuRange { + range_index: preview.mesh.draw_ranges.len(), + vertex_start: 0, + vertex_capacity: 0, + material_index: preview.shadow_material_index, + }; + let mut app = StaticVulkanApp::new( + preview, + None, + None, + shadow_range, + 0, + 0, + None, + 2, + mission, + loaded.object_count, + None, + None, + false, + None, + StartupTimings::default(), + ); + let plant = app + .building_collision_bindings + .iter() + .find(|binding| binding.object_index == 4 && binding.component_index == 0) + .cloned() + .expect("static PLANT root collision binding"); + let hierarchy = app.component_pose_buffers[4][0] + .as_ref() + .expect("PLANT current root pose"); + let plant_nodes = + building_collision_nodes(&plant, Some(hierarchy), app.component_mount_poses[4][0]) + .expect("PLANT current collision nodes"); + let plant_node = plant_nodes + .iter() + .copied() + .find(|node| node.node == NodeId(1) && node.slot == SlotId(0)) + .expect("native fixture node and slot"); + let body_to_world = |point| { + plant + .root_transform + .try_transform_scaled_point(point, plant.owner_scale) + .expect("finite placed-body transform") + }; + let world_wall = BODY_WALL.map(body_to_world); + let world_door = BODY_DOOR.map(body_to_world); + let direct_wall_hit = query_building_segment_world(&plant.model, &[plant_node], world_wall) + .expect("direct PLANT body-space query") + .expect("verified PLANT wall hit"); + assert_eq!(direct_wall_hit.node, NodeId(1)); + assert_eq!(direct_wall_hit.slot, SlotId(0)); + assert_eq!(direct_wall_hit.batch_index, 5); + assert_eq!(direct_wall_hit.triangle_descriptor_index, 62); + let closed_door_hit = query_building_segment_world(&plant.model, &plant_nodes, world_door) + .expect("linked PLANT door query") + .expect("the default Node3 door blocks the center path"); + assert_eq!(closed_door_hit.node, NodeId(3)); + assert_eq!(closed_door_hit.batch_index, 100); + assert_eq!(closed_door_hit.triangle_descriptor_index, 855); + let terrain_bounds = app + .terrain + .surface_bounds() + .expect("AutoDemo terrain surface bounds"); + for point in world_wall.into_iter().chain(world_door) { + for axis in 0..2 { + assert!( + (terrain_bounds[0][axis]..=terrain_bounds[1][axis]).contains(&point[axis]), + "native fixture endpoint {point:?} is outside terrain bounds {terrain_bounds:?}" + ); + } + let floor = app + .terrain + .height_at([point[0], point[1]]) + .expect("fixture terrain query"); + if let Some(floor) = floor { + assert!( + point[2] >= floor, + "native fixture height {} is below terrain {floor} at {point:?}", + point[2] + ); + } else { + let foundation = app + .terrain + .basement_foundation() + .expect("missing terrain height needs a BAS foundation"); + assert!(!foundation.fragments.is_empty()); + let source_floor = terrain + .height_at([point[0], point[1]]) + .expect("source fixture terrain query") + .expect("BAS-cut fixture endpoint has source Land.msh terrain"); + eprintln!( + "native fixture endpoint {point:?} has source Land.msh floor {source_floor} but no BAS-foundation surface" + ); + } + let mut constrained = FreeFlightCamera { + position: point, + yaw: 0.0, + pitch: 0.0, + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: 0.0, + ground_clearance: 0.0, + }; + constrained + .keep_above_terrain(&app.terrain) + .expect("terrain constraint preserves native fixture height"); + assert_eq!(constrained.position, point); + } + + set_collision_test_segment(&mut app, world_wall); + let zero_delta = FrameClockSample { + id: SceneSampleId::Replay(0), + time_ms: 0, + delta_seconds: 0.0, + }; + app.prepare_scene_update(zero_delta, [1280.0, 720.0]) + .expect("zero-delta resume initialization"); + assert_eq!(app.free_camera.unwrap().position, world_wall[0]); + assert_eq!(app.record_presented_scene_frame(), 0); + + let wall_frame = FrameClockSample { + id: SceneSampleId::Replay(1), + time_ms: 0, + delta_seconds: 0.1, + }; + app.prepare_scene_update(wall_frame, [1280.0, 720.0]) + .expect("high-speed wall movement through the app update path"); + assert_eq!(app.free_camera.unwrap().position, world_wall[0]); + app.pressed_keys.clear(); + app.pressed_keys.insert(KeyCode::KeyD); + app.prepare_scene_update(wall_frame, [1024.0, 768.0]) + .expect("retry of the pending wall frame"); + assert_eq!(app.free_camera.unwrap().position, world_wall[0]); + assert_eq!(app.record_presented_scene_frame(), 1); + + set_collision_test_segment(&mut app, world_door); + app.prepare_scene_update( + FrameClockSample { + id: SceneSampleId::Replay(2), + time_ms: 0, + delta_seconds: 0.1, + }, + [1024.0, 768.0], + ) + .expect("the closed Node3 door blocks through the app update path"); + assert_eq!(app.free_camera.unwrap().position, world_door[0]); + assert_eq!(app.record_presented_scene_frame(), 2); + + let control = assets + .object_component_controls + .get(4) + .and_then(|components| components.first()) + .and_then(Option::as_ref) + .expect("PLANT root retains its CTLD control"); + let node3_binding = control + .bindings + .iter() + .find(|binding| binding.node_index == 3) + .expect("PLANT CTLD binding for the authored Node3 door"); + assert_eq!(node3_binding.frame_a, 0.0); + assert_eq!(node3_binding.frame_b, 1.0); + assert_eq!(node3_binding.initial_blend, 0.0); + assert_eq!( + control + .typed_records + .iter() + .filter(|record| record.tag == 21) + .count(), + 0, + "the PLANT control has no tag-21 record to advance Node3 from its initial frame" + ); + let mut open_node_times = + static_preview_node_times(plant.model.node_count, &control.bindings) + .expect("actual initial PLANT CTLD node times"); + assert_eq!(open_node_times[3], Some(AnimationTime(0.0))); + // This samples the door's authored frame B directly; it does not simulate a mission gate. + open_node_times[3] = Some(AnimationTime(1.0)); + let open_hierarchy = node38_sampled_hierarchy_at_times(&plant.model, &open_node_times) + .expect("sample the actual PLANT model at its authored Node3 frame B"); + app.component_pose_buffers[4][0] = Some(open_hierarchy); + update_dynamic_component_geometry( + &app.dynamic_component_geometry, + &app.component_pose_buffers, + &mut app.component_mount_poses, + &mut app.mesh, + ) + .expect("update the scene hierarchy for the authored door pose"); + let open_hierarchy = app.component_pose_buffers[4][0] + .as_ref() + .expect("sampled PLANT root pose"); + let open_nodes = building_collision_nodes( + &plant, + Some(open_hierarchy), + app.component_mount_poses[4][0], + ) + .expect("sampled PLANT collision nodes"); + assert!( + query_building_segment_world(&plant.model, &open_nodes, world_door) + .expect("open PLANT door query") + .is_none(), + "the authored Node3 time-1 pose leaves the doorway ray clear" + ); + + set_collision_test_segment(&mut app, world_door); + app.update_free_camera(0.1, [1024.0, 768.0]) + .expect("open doorway movement through the app camera update"); + let position = app.free_camera.unwrap().position; + for axis in 0..3 { + assert!((position[axis] - world_door[1][axis]).abs() < 1.0e-4); + } + } + + fn set_collision_test_segment(app: &mut StaticVulkanApp, segment: [[f32; 3]; 2]) { + let delta = sub3(segment[1], segment[0]); + let horizontal_distance = (delta[0] * delta[0] + delta[1] * delta[1]).sqrt(); + app.free_camera = Some(FreeFlightCamera { + position: segment[0], + yaw: delta[1].atan2(delta[0]), + pitch: delta[2].atan2(horizontal_distance), + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: length3(delta).expect("nonzero native test segment") / 0.1, + ground_clearance: 0.0, + }); + app.pressed_keys.clear(); + app.pressed_keys.insert(KeyCode::KeyW); + } + + fn socket_timeline_control() -> PreparedComponentControl { let mut fixed_words = [0; 44]; fixed_words[2] = 0; fixed_words[6] = u32::MAX; fixed_words[9] = 1.0_f32.to_bits(); fixed_words[10] = 1.0_f32.to_bits(); - let archive = || { - fparkan_path::normalize_relative(b"test.rlb", fparkan_path::PathPolicy::HostCompatible) - .expect("test archive path") - }; PreparedComponentControl { - source: fparkan_resource::ResourceKey { - archive: archive(), - name: ResourceName(b"animated.ctl".to_vec()), + source: ResourceKey { + archive: normalize_relative(b"test.rlb", PathPolicy::HostCompatible) + .expect("archive path"), + name: resource_name(b"animated.ctl"), type_id: None, }, - model_node_count: 1, native_profile_words: [0; 27], + model_node_count: 2, bindings: vec![], - timeline_rows: vec![fparkan_assets::PreparedControlTimelineRow { + timeline_rows: vec![PreparedControlTimelineRow { row_index: 0, node_index: 0, frame_a: 0.0, @@ -7421,16 +12282,17 @@ mod tests { raw_parameters: [u32::MAX, u32::MAX, 1.0_f32.to_bits(), 0], flags: 0, }], - typed_records: vec![fparkan_assets::PreparedControlTypedRecord { + typed_records: vec![PreparedControlTypedRecord { tag: 21, fixed_words, values: vec![0], string_bytes: vec![], gate_provider: PreparedControlGateProvider::Resolved { key: 0, - ndp_source: fparkan_resource::ResourceKey { - archive: archive(), - name: ResourceName(b"animated.ndp".to_vec()), + ndp_source: ResourceKey { + archive: normalize_relative(b"test.rlb", PathPolicy::HostCompatible) + .expect("NDP archive path"), + name: resource_name(b"animated.ndp"), type_id: None, }, row_count: 1, @@ -7438,595 +12300,622 @@ mod tests { }, }], node_data: None, - effect_section: fparkan_assets::PreparedControlEffectSection::Absent, + effect_section: PreparedControlEffectSection::Absent, initial_effect_group: None, point_list: None, } } + fn control_point(position: [f32; 3], direction: [f32; 3]) -> PreparedControlPoint { + PreparedControlPoint { + raw_flags: 0, + raw_signed_field_04: 0, + raw_signed_field_08: 0, + position, + direction, + name: [0; 32], + } + } + + fn assert_matrix_columns(matrix: [[f32; 4]; 4], expected: [[f32; 3]; 3]) { + for (column, expected) in expected.into_iter().enumerate() { + for row in 0..3 { + assert!( + (matrix[row][column] - expected[row]).abs() < 1.0e-5, + "column {column} row {row}: {} != {}", + matrix[row][column], + expected[row] + ); + } + } + } + + fn write_effect_u32(body: &mut [u8], offset: usize, value: u32) { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn write_effect_f32(body: &mut [u8], offset: usize, value: f32) { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn write_effect_vec3(body: &mut [u8], offset: usize, value: [f32; 3]) { + for (axis, component) in value.into_iter().enumerate() { + write_effect_f32(body, offset + axis * 4, component); + } + } + #[test] - fn control_timeline_and_ndp_gate_change_component_pose_at_sampled_time() { - let model = control_pose_test_model(); - let control = control_pose_test_control(); + fn tag21_clock_moves_msh_vertices_normals_child_socket_and_fx_anchor() { + let parent_model = animated_socket_model(); + let child_model = static_child_model(); + let identity_transform = LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0; 3], + }; + let initial_parent_pose = + node38_sampled_hierarchy_at_times(&parent_model, &[Some(AnimationTime(0.0)), None]) + .expect("initial parent pose"); + let initial_child_pose = + node38_fallback_hierarchy(&child_model).expect("static child pose"); + let initial_mount = + node38_pose_from_hierarchy(&initial_parent_pose, 1).expect("parent socket pose"); + let parent_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer( + &parent_model, + identity_transform, + [1.0; 3], + &initial_parent_pose, + ) + .expect("initial parent mesh"); + let child_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( + &child_model, + identity_transform, + [1.0; 3], + &initial_child_pose, + initial_mount, + ) + .expect("initial mounted child mesh"); + let parent_vertex_count = parent_mesh.vertices.len(); + assert_eq!(parent_vertex_count, 3); + assert_eq!(child_mesh.vertices.len(), 3); + let mut vertices = parent_mesh + .vertices + .into_iter() + .chain(child_mesh.vertices) + .collect::<Vec<_>>(); + let environment_vertex_start = + u32::try_from(vertices.len()).expect("environment vertex base fits u32"); + let mut environment_vertices = shadow_placeholder_vertices(); + for vertex in &mut environment_vertices { + vertex.position[0] += 1000.0; + } + vertices.extend(environment_vertices); + let mut indices = vec![0, 1, 2, 3, 4, 5]; + indices.extend( + quad_indices(1) + .expect("environment quad indices") + .into_iter() + .map(|index| index + environment_vertex_start), + ); + let draw_range = |first_index, index_count| VulkanStaticDrawRange { + first_index, + index_count, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: world_pipeline_state( + LegacyBlendMode::Opaque, + LegacyDepthMode::TestReadOnly, + ), + terrain_base_only: false, + alpha_test_reference: 0, + }; + let mut mesh = VulkanStaticMesh { + vertices, + indices, + draw_ranges: vec![draw_range(0, 3), draw_range(3, 3), draw_range(6, 6)], + }; + let control = socket_timeline_control(); let gate_values = initial_control_gate_values(&control); - let mut raw_state = ControlInstanceState::default(); - let raw_initial = sample_control_node_times(&control, 0, &gate_values, &mut raw_state) - .expect("initial raw control timeline"); - let raw_later = sample_control_node_times(&control, 225, &gate_values, &mut raw_state) - .expect("later raw control timeline"); - assert_eq!(raw_initial, vec![Some(0.0)]); - assert!((raw_later[0].expect("node 0 time") - 0.225).abs() < 1.0e-5); - - let mut state = ControlInstanceState::default(); - let initial = static_preview_control_pose_buffer(&model, &control, 0, &mut state) - .expect("initial control pose") - .expect("sampled initial hierarchy"); - let later = static_preview_control_pose_buffer(&model, &control, 225, &mut state) - .expect("later control pose") - .expect("sampled later hierarchy"); - - assert_eq!(initial.poses[0].translation, [0.0; 3]); - assert!( - (later.poses[0].translation[1] - 0.45).abs() < 1.0e-4, - "later sampled pose: {:?}", - later.poses[0] - ); - - let mut closed_gate = control.clone(); - let ndp_source = match &control.typed_records[0].gate_provider { - PreparedControlGateProvider::Resolved { ndp_source, .. } => ndp_source.clone(), - _ => panic!("fixture has a resolved NDP source"), - }; - closed_gate.typed_records[0].gate_provider = PreparedControlGateProvider::Resolved { - key: 0, - ndp_source, - row_count: 1, - initial_type1_value: 0.0, - }; - let mut closed_state = ControlInstanceState::default(); - let closed_initial = - static_preview_control_pose_buffer(&model, &closed_gate, 0, &mut closed_state) - .expect("closed-gate initial pose") - .expect("closed-gate initial hierarchy"); - let closed_later = - static_preview_control_pose_buffer(&model, &closed_gate, 225, &mut closed_state) - .expect("closed-gate control pose") - .expect("closed-gate hierarchy"); - assert_eq!(initial.poses[0], closed_initial.poses[0]); - assert_eq!(closed_initial.poses[0], closed_later.poses[0]); - assert_ne!(later.poses[0], closed_later.poses[0]); - - let mut mismatched_control = control.clone(); - mismatched_control.model_node_count += 1; - let mismatch = static_preview_control_pose_buffer( - &model, - &mismatched_control, - 225, - &mut ControlInstanceState::default(), + let mut shadow_casters = vec![test_shadow_caster(0, [0.0; 3])]; + shadow_casters[0].triangles = static_mesh_shadow_triangles_in_index_range(&mesh, 0, 6) + .expect("initial caster triangle geometry"); + let dynamic_shadow_casters = [DynamicShadowCaster { + caster_index: 0, + object_index: 0, + root_transform: identity_transform, + owner_scale: [1.0; 3], + initial_sphere: shadow_casters[0].sphere, + component_draw_range_indices: vec![0, 1], + component_spheres: vec![ + DynamicShadowSphereSource { + component_index: 0, + center: [0.0; 3], + radius: 1.0, + mounted: false, + }, + DynamicShadowSphereSource { + component_index: 1, + center: [0.0; 3], + radius: 1.0, + mounted: true, + }, + ], + }]; + let mut instances = vec![vec![ + Some(ControlAnimationInstance { + control, + model: parent_model.clone(), + gate_values, + state: ControlInstanceState::default(), + last_sampled_node_times: Vec::new(), + reported_unsupported_fallback: false, + }), + None, + ]]; + let mut poses = vec![vec![Some(initial_parent_pose), Some(initial_child_pose)]]; + let mut mounts = vec![vec![None, Some(initial_mount)]]; + let geometry = vec![vec![ + Some(DynamicComponentGeometry { + model: parent_model, + root_transform: identity_transform, + owner_scale: [1.0; 3], + parent_mount: None, + vertex_start: 0, + vertex_count: parent_vertex_count, + }), + Some(DynamicComponentGeometry { + model: child_model, + root_transform: identity_transform, + owner_scale: [1.0; 3], + parent_mount: Some((0, 1)), + vertex_start: parent_vertex_count, + vertex_count: 3, + }), + ]]; + + // The fixture's resolved NDP row supplies the native initial value 1.0. + update_control_animation_pose_buffers(&mut instances, &mut poses, 0) + .expect("initial active timeline sample"); + let first_root_pose = poses[0][0].as_ref().expect("first root pose").poses[0]; + update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh) + .expect("initial geometry update"); + let range_remap = reorder_draw_ranges(&mut mesh, &[2, 0, 1]).expect("environment reorder"); + let mut dynamic_shadow_casters = dynamic_shadow_casters; + remap_dynamic_shadow_caster_draw_ranges(&mut dynamic_shadow_casters, &range_remap) + .expect("dynamic shadow draw-range remap"); + refresh_dynamic_shadow_casters( + &mut shadow_casters, + &dynamic_shadow_casters, + &mesh, + &poses, + &mounts, + ) + .expect("initial caster geometry refresh"); + let encloses_triangles = |sphere: ShadowSphere, triangles: &[ShadowTriangle]| { + triangles + .iter() + .flat_map(|triangle| triangle.positions) + .all(|point| { + let offset = sub3(point, sphere.center); + let distance = offset[0].hypot(offset[1]).hypot(offset[2]); + distance.is_finite() && distance <= sphere.radius + 1.0e-4 + }) + }; + let first_shadow_sphere = shadow_casters[0].sphere; + assert!(encloses_triangles( + first_shadow_sphere, + &shadow_casters[0].triangles + )); + assert_eq!( + dynamic_shadow_casters[0].component_draw_range_indices, + [1, 2] + ); + assert_eq!(shadow_casters[0].triangles.len(), 2); + assert_eq!( + shadow_casters[0].triangles[0].positions[0], + mesh.vertices[0].position + ); + assert_eq!( + shadow_casters[0].triangles[1].positions[0], + mesh.vertices[parent_vertex_count].position + ); + let first_shadow_triangles = shadow_casters[0].triangles.clone(); + let first_parent_vertex = mesh.vertices[0]; + let first_child_vertex = mesh.vertices[parent_vertex_count]; + let first_mount = mounts[0][1].expect("initial child mount"); + let first_fx_anchor = control_type4_world_from_effect( + identity_matrix(), + [1.0; 3], + identity_transform, + Some(first_mount), + control_effect_owner_node_pose(poses[0][1].as_ref(), true, 0) + .expect("mounted FX owner pose"), + ) + .expect("initial FX anchor"); + + update_control_animation_pose_buffers(&mut instances, &mut poses, 225) + .expect("later active timeline sample"); + let later_root_pose = poses[0][0].as_ref().expect("later root pose").poses[0]; + assert_ne!(first_root_pose, later_root_pose); + update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh) + .expect("later geometry update"); + refresh_dynamic_shadow_casters( + &mut shadow_casters, + &dynamic_shadow_casters, + &mesh, + &poses, + &mounts, + ) + .expect("later caster geometry refresh"); + let later_shadow_sphere = shadow_casters[0].sphere; + let later_shadow_triangles = shadow_casters[0].triangles.clone(); + assert!(encloses_triangles( + later_shadow_sphere, + &later_shadow_triangles + )); + assert_ne!(first_shadow_triangles, shadow_casters[0].triangles); + assert_eq!( + shadow_casters[0].triangles[0].positions[0], + mesh.vertices[0].position + ); + let later_parent_vertex = mesh.vertices[0]; + let later_child_vertex = mesh.vertices[parent_vertex_count]; + let later_mount = mounts[0][1].expect("later child mount"); + let later_fx_anchor = control_type4_world_from_effect( + identity_matrix(), + [1.0; 3], + identity_transform, + Some(later_mount), + control_effect_owner_node_pose(poses[0][1].as_ref(), true, 0) + .expect("mounted FX owner pose"), ) - .expect_err("control/mesh node-count mismatch is rejected"); - assert!(mismatch.contains("does not match MSH node count")); + .expect("later FX anchor"); + + assert_ne!(first_parent_vertex.position, later_parent_vertex.position); + assert_ne!(first_parent_vertex.normal, later_parent_vertex.normal); + assert_ne!(first_mount.translation, later_mount.translation); + assert_eq!(first_child_vertex.position, first_mount.translation); + assert_eq!(later_child_vertex.position, later_mount.translation); + assert_ne!(first_fx_anchor[0][3], later_fx_anchor[0][3]); + assert_ne!(first_fx_anchor[1][3], later_fx_anchor[1][3]); + + update_control_animation_pose_buffers(&mut instances, &mut poses, 225) + .expect("repeat the same timeline sample"); + update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh) + .expect("repeat the same geometry update"); + refresh_dynamic_shadow_casters( + &mut shadow_casters, + &dynamic_shadow_casters, + &mesh, + &poses, + &mounts, + ) + .expect("repeat the same caster refresh"); + assert_eq!(shadow_casters[0].sphere, later_shadow_sphere); + assert_eq!(shadow_casters[0].triangles, later_shadow_triangles); + + update_control_animation_pose_buffers(&mut instances, &mut poses, 0) + .expect("return to the initial timeline sample"); + update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh) + .expect("return to the initial geometry"); + refresh_dynamic_shadow_casters( + &mut shadow_casters, + &dynamic_shadow_casters, + &mesh, + &poses, + &mounts, + ) + .expect("rebuild the initial caster after a later pose"); + assert_eq!(shadow_casters[0].sphere, first_shadow_sphere); + assert_eq!(shadow_casters[0].triangles, first_shadow_triangles); } #[test] - fn exto_parent_control_updates_visible_child_mount_without_draw_geometry() { - let mut model = control_pose_test_model(); - let mut socket_node = vec![u8::MAX; model.node_stride]; - socket_node[..2].copy_from_slice(&[0, 0]); - socket_node[2..4].copy_from_slice(&0_u16.to_le_bytes()); - socket_node[4..6].copy_from_slice(&u16::MAX.to_le_bytes()); - socket_node[6..8].copy_from_slice(&1_u16.to_le_bytes()); - model.nodes_raw.extend(socket_node); - model.node_count = 2; - let mut control = control_pose_test_control(); - control.model_node_count = 2; - let unit_record = |immediate_child_count| fparkan_prototype::UnitComponentRecord { + fn visible_exto_parent_refreshes_child_mount_without_parent_geometry() { + let make_record = |kind, immediate_child_count| fparkan_prototype::UnitComponentRecord { archive_raw: [0; 32], resource_raw: [0; 32], - kind: 0, + kind, parent_or_link: 1, description_raw: [0; 32], tail0: 0, immediate_child_count, }; - let unit_tree = unit_component_tree(&[unit_record(1), unit_record(1), unit_record(0)]) - .expect("preorder Unit DAT tree"); - let controls = vec![None, Some(control.clone()), None]; - assert!(unit_component_is_visible(1, Some(PROTOTYPE_TYPE_EXTO))); - assert!(unit_component_is_visible(2, Some(PROTOTYPE_TYPE_EXTO))); - assert!(component_chain_has_tag21(Some(&unit_tree), &controls, 2)); - assert_eq!(unit_tree[1].parent_index, Some(0)); - assert_eq!(unit_tree[2].parent_index, Some(1)); - assert!( - unit_tree[2] - .parent_index - .is_some_and(|parent_index| parent_index < 2), - "Unit DAT preorder places a parent before its child" - ); - let mut state = ControlInstanceState::default(); - let initial_hierarchy = static_preview_control_pose_buffer(&model, &control, 0, &mut state) - .expect("initial EXTO parent pose") - .expect("initial parent hierarchy"); - let later_hierarchy = static_preview_control_pose_buffer(&model, &control, 225, &mut state) - .expect("animated EXTO parent pose") - .expect("animated parent hierarchy"); - let mut pose_buffers = vec![vec![None, Some(initial_hierarchy), None]]; - let mounts = [DynamicComponentMount { - object_index: 0, - component_index: 2, - parent_index: 1, - socket: 1, - }]; - let initial = node38_pose_from_hierarchy( - pose_buffers[0][1].as_ref().expect("EXTO parent hierarchy"), - 1, - ) - .expect("parent socket"); - let mut mount_poses = vec![vec![None, None, Some(initial)]]; - pose_buffers[0][1] = Some(later_hierarchy); - let parent_geometry = DynamicComponentGeometry { - object_index: 0, - component_index: 1, - model: Arc::new(model.clone()), - transform: LegacyIron3dEulerTransform { - translation: [0.0; 3], - orientation_radians: [0.0; 3], - }, - scale: [1.0; 3], - vertex_start: 0, - vertex_count: 0, - shadow_triangle_range: None, + let records = vec![ + make_record(0, 1), + make_record(PROTOTYPE_TYPE_EXTO, 1), + make_record(PROTOTYPE_TYPE_EXTO, 0), + ]; + let tree = unit_component_tree(&records).expect("valid three-record component tree"); + assert_eq!(tree[2].parent_index, Some(1)); + assert!(tree[1].parent_index.expect("parent has root") < 2); + assert!(unit_component_is_visible(1, Some(records[1].kind))); + + let identity_transform = LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0; 3], }; - let empty_parent_mesh = project_dynamic_component_geometry( - &parent_geometry, - pose_buffers[0][1].as_ref().expect("EXTO parent hierarchy"), - None, + let mut parent_model = animated_socket_model(); + parent_model.slots.clear(); + parent_model.positions.clear(); + parent_model.normals = None; + parent_model.uv0 = None; + parent_model.uv1 = None; + parent_model.indices.clear(); + parent_model.batches.clear(); + let initial_parent_pose = + node38_sampled_hierarchy_at_times(&parent_model, &[Some(AnimationTime(0.0)), None]) + .expect("animated parent hierarchy without mesh geometry"); + let parent_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer( + &parent_model, + identity_transform, + [1.0; 3], + &initial_parent_pose, ) - .expect("zero-geometry EXTO parent"); - assert!(empty_parent_mesh.vertices.is_empty()); - assert!(empty_parent_mesh.indices.is_empty()); + .expect("parent with no geometry projects successfully"); + assert!(parent_mesh.vertices.is_empty()); + + let child_model = static_child_model(); + let child_pose = node38_fallback_hierarchy(&child_model).expect("static child pose"); + let initial_mount = + node38_pose_from_hierarchy(&initial_parent_pose, 1).expect("visible parent socket"); + let mut mounts = vec![vec![None, None, Some(initial_mount)]]; + let parent_mount = tree[2] + .parent_index + .filter(|parent_index| { + mounts[0][*parent_index].is_some() + || unit_component_is_visible(*parent_index, Some(records[*parent_index].kind)) + }) + .map(|parent_index| { + ( + parent_index, + usize::try_from(records[2].parent_or_link).expect("valid EXTO parent socket"), + ) + }); + assert_eq!(parent_mount, Some((1, 1))); - assert!( - refresh_dynamic_component_mounts(&mounts, &pose_buffers, &mut mount_poses) - .expect("refresh child mount") - ); - assert_eq!( - mount_poses[0][2].expect("child mount").translation, - [0.0, 2.45, 0.0] + let child_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( + &child_model, + identity_transform, + [1.0; 3], + &child_pose, + initial_mount, + ) + .expect("initial child mesh"); + let mut mesh = VulkanStaticMesh { + vertices: child_mesh.vertices, + indices: child_mesh.indices, + draw_ranges: Vec::new(), + }; + let control = socket_timeline_control(); + let controls = vec![None, Some(control.clone()), None]; + assert!(control_tree_chain_has_tag21(2, Some(&tree), &controls)); + let mut instances = vec![vec![ + None, + Some(ControlAnimationInstance { + gate_values: initial_control_gate_values(&control), + control, + model: parent_model, + state: ControlInstanceState::default(), + last_sampled_node_times: Vec::new(), + reported_unsupported_fallback: false, + }), + None, + ]]; + let mut poses = vec![vec![None, Some(initial_parent_pose), Some(child_pose)]]; + let geometry = vec![vec![ + None, + None, + Some(DynamicComponentGeometry { + model: child_model, + root_transform: identity_transform, + owner_scale: [1.0; 3], + parent_mount, + vertex_start: 0, + vertex_count: mesh.vertices.len(), + }), + ]]; + + update_control_animation_pose_buffers(&mut instances, &mut poses, 0) + .expect("initial visible parent timeline sample"); + update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh) + .expect("initial child mount refresh"); + let initial_child_vertex = mesh.vertices[0].position; + let initial_child_mount = mounts[0][2].expect("initial child mount"); + assert_eq!(initial_child_vertex, initial_child_mount.translation); + + update_control_animation_pose_buffers(&mut instances, &mut poses, 225) + .expect("later visible parent timeline sample"); + update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh) + .expect("child mount refresh without parent geometry descriptor"); + let later_child_mount = mounts[0][2].expect("later child mount"); + assert_ne!( + initial_child_mount.translation, + later_child_mount.translation ); - // The EXTO parent has a sampled Node38 hierarchy but no projected draw - // vertices. Its hierarchy still drives the visible child's mount. + assert_eq!(mesh.vertices[0].position, later_child_mount.translation); } #[test] - fn live_tick_and_pending_resize_keep_the_scene_sample_while_refreshing_projection() { - let model = Arc::new(control_pose_drawable_test_model()); - let control = control_pose_test_control(); - let dynamic = DynamicComponentGeometry { - object_index: 0, - component_index: 0, - model: Arc::clone(&model), - transform: LegacyIron3dEulerTransform { - translation: [0.0; 3], - orientation_radians: [0.0; 3], - }, - scale: [1.0; 3], - vertex_start: 0, - vertex_count: 3, - shadow_triangle_range: Some(0..1), - }; - let mut state = ControlInstanceState::default(); - let initial_pose = static_preview_control_pose_buffer(&model, &control, 0, &mut state) - .expect("initial hierarchy") - .expect("initial Node38 pose"); - let initial_mesh = project_dynamic_component_geometry(&dynamic, &initial_pose, None) - .expect("initial projected component"); - let initial_triangles = static_mesh_shadow_triangles(&initial_mesh); - let baseline = ShadowSphere { + fn dynamic_shadow_sphere_union_keeps_the_containing_input_sphere() { + let small = ShadowSphere { center: [0.0; 3], radius: 1.0, }; - let mut land = fparkan_terrain_format::LandMeshDocument { - streams: Vec::new(), - nodes_raw: Vec::new(), - slots: fparkan_terrain_format::TerrainSlotTable { - header_raw: Vec::new(), - slots_raw: Vec::new(), - }, - positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], - normals: Vec::new(), - uv0: Vec::new(), - accelerator: Vec::new(), - aux14: Vec::new(), - aux18: Vec::new(), - faces: vec![fparkan_terrain_format::TerrainFace28 { - flags: FullSurfaceMask(1), - material_tag: 0, - aux_tag: 0, - vertices: [0, 1, 2], - neighbors: [None, None, None], - tail_raw: [0; 8], - raw: [0; 28], - }], - }; - set_test_terrain_lod0_range(&mut land); - let terrain = Arc::new(TerrainWorld::from_land_msh(&land).expect("test terrain")); - let free_camera = FreeFlightCamera { - position: [0.25, 0.25, 0.0], - yaw: 0.0, - pitch: 0.0, - horizontal_fov: 1.0, - near_plane: 0.1, - far_plane: 100.0, - move_speed: 1.0, - ground_clearance: 0.0, + let large = ShadowSphere { + center: [1.0, 0.0, 0.0], + radius: 10.0, }; - let view_camera = free_camera.preview_frame(16.0 / 9.0); - let preview = StaticPreviewScene { - mesh: initial_mesh.clone(), - native_handedness_by_range: HashMap::new(), - materials: Vec::new(), - material_animations: Vec::new(), - sun_occlusion_range_indices: Vec::new(), - shadow_casters: vec![ShadowCaster { - id: 0, - world_axes: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], - sphere: baseline, - native_kind: 4, - triangles: initial_triangles.clone(), - }], - shadow_sphere_baselines: vec![baseline], - shadow_receivers: Vec::new(), - shadow_material_index: 0, - terrain, - component_pose_buffers: vec![vec![Some(initial_pose)]], - component_mount_poses: vec![vec![None]], - control_animation_instances: vec![ControlAnimationInstance { - object_index: 0, - component_index: 0, - model: Arc::clone(&model), - control, - state, - }], - dynamic_component_geometry: vec![dynamic], - dynamic_component_mounts: Vec::new(), - shadow_caster_indices_by_object: vec![Some(0)], - camera: view_camera.vulkan_camera(), - view_camera, - captured_viewport: None, - free_camera: None, - camera_mode: "test", - mesh_components: 1, - terrain_components: 0, - }; - let mut app = StaticVulkanApp::new( - preview, - None, - None, - EnvironmentGpuRange { - range_index: usize::MAX, - vertex_start: initial_mesh.vertices.len(), - vertex_capacity: 0, - material_index: 0, - }, - initial_mesh.vertices.len(), - initial_mesh.indices.len(), - None, - 0, - None, - "control-test", - 1, - None, - false, - StartupTimings::default(), - ); - app.free_camera = Some(free_camera); - app.pressed_keys.insert(KeyCode::KeyW); - app.materials = packet_test_materials(); - app.material_animations = vec![random_material_binding(0)]; - let empty_range = app.shadow_range; - app.environment_gpu = Some(EnvironmentGpuScene { - material_animations: vec![random_material_binding(1)], - sky_layers: Vec::new(), - sprites: Vec::new(), - rain: empty_range, - snow: empty_range, - lightning: empty_range, - lightning_material_index: 0, - }); - let start = app.last_tick; - let sample = app.sample_live_frame_clock_at(start + Duration::from_millis(225)); - assert_eq!(sample.time_ms, 225); - assert!((sample.delta_seconds - 0.225).abs() < f32::EPSILON); - let mut update = app - .prepare_scene_update(sample, [1280.0, 720.0]) - .expect("apply one full live tick to the scene"); - assert!(update.environment.is_none()); - assert!(update.environment_material_update.is_some()); - let world_material_packet = update.material_update.clone(); - let sky_material_packet = update.environment_material_update.clone(); - assert_eq!(app.material_animations[0].base.random_state, 2_531_011); - assert_eq!( - app.environment_gpu.as_ref().unwrap().material_animations[0] - .base - .random_state, - 2_531_011 - ); - let expected_resized_camera = app - .free_camera - .expect("free-flight input controller") - .preview_frame(4.0 / 3.0); - let camera_after_tick = app.free_camera.expect("free-flight camera").position; - assert!((camera_after_tick[0] - 0.35).abs() < 1.0e-5); - assert!((camera_after_tick[1] - 0.25).abs() < 1.0e-5); - assert_eq!(app.material_time_ms, sample.time_ms); - let weather = WeatherInput { - rain: WeatherState::new(true, 0.03, [0.7, 0.8, 1.0, 0.6]), - snow: WeatherState::new(true, 0.03, [0.9, 0.95, 1.0, 0.8]), - ..WeatherInput::clear() - }; - let mut weather_system = EnvironmentSystem::new(0xC1_225); - let first_environment_frame = weather_system.update( - 0.225, - weather, - environment_camera_for_preview(&update.camera, update.aspect, update.viewport), - ); - let mut precipitation_counts = [0usize; 2]; - for primitive in &first_environment_frame.primitives { - if let EnvironmentPrimitive::Particle { kind, .. } = primitive { - let slot = match kind { - PrecipitationKind::Rain => 0, - PrecipitationKind::Snow => 1, - }; - precipitation_counts[slot] += 1; - } - } - assert!(precipitation_counts.iter().all(|count| *count > 0)); - update.environment = Some(DynamicEnvironmentFrame { - atmosphere: TypedAtmosphere { - marker: 0, - version: 0, - tracks: Vec::new(), - trailer: Default::default(), - } - .sample(0.225), - sky_mesh: SkySystem::new(Default::default(), SkyMaterials::default()) - .expect("test sky topology") - .mesh() - .clone(), - environment_frame: first_environment_frame.clone(), - absolute_time_seconds: 0.225, - }); - assert!(app.lifecycle.prepare(update.clone())); - let control_state_after_tick = app.control_animation_instances[0].state.clone(); - assert_eq!( - app.sample_live_frame_clock_at(start + Duration::from_millis(500)), - sample - ); - assert_eq!(app.simulation_elapsed, Duration::from_millis(225)); - assert_eq!(app.lifecycle.pending.as_ref(), Some(&update)); - assert_eq!(app.mesh.indices, initial_mesh.indices); - assert_eq!(app.mesh.vertices.len(), initial_mesh.vertices.len()); - assert_ne!( - initial_mesh.vertices[0].position, - app.mesh.vertices[0].position - ); - assert!((app.mesh.vertices[0].position[1] - 0.45).abs() < 1.0e-4); - let b_vertices = app.mesh.vertices.clone(); - let b_triangles = app.shadow_casters[0].triangles.clone(); - let b_sphere = app.shadow_casters[0].sphere; - assert_ne!(b_triangles, initial_triangles); - assert!(b_sphere.radius > baseline.radius); - - app.free_camera.as_mut().unwrap().look_delta(64.0, -24.0); - let controller_after_input = app.free_camera.unwrap(); - app.refresh_pending_viewport([800.0, 600.0]) - .expect("refresh only viewport-dependent state after recreation"); - let refreshed = app - .lifecycle - .pending - .as_ref() - .expect("pending sample") - .clone(); - assert_eq!(refreshed.sample, sample); - assert_eq!(refreshed.material_update, world_material_packet); - assert_eq!(refreshed.environment_material_update, sky_material_packet); - assert_eq!(app.material_animations[0].base.random_state, 2_531_011); assert_eq!( - app.environment_gpu.as_ref().unwrap().material_animations[0] - .base - .random_state, - 2_531_011 - ); - app.refresh_pending_viewport([1024.0, 768.0]) - .expect("refresh the same pending sample a second time"); - let repeated = app.lifecycle.pending.as_ref().expect("pending sample"); - assert_eq!(repeated.sample, sample); - assert_eq!(repeated.material_update, world_material_packet); - assert_eq!(repeated.environment_material_update, sky_material_packet); - assert_eq!(repeated.viewport, [1024.0, 768.0]); - assert_eq!(repeated.aspect, 4.0 / 3.0); - assert_eq!(app.material_animations[0].base.random_state, 2_531_011); - assert_eq!( - app.environment_gpu.as_ref().unwrap().material_animations[0] - .base - .random_state, - 2_531_011 - ); - assert_eq!(refreshed.viewport, [800.0, 600.0]); - assert_eq!(refreshed.aspect, 4.0 / 3.0); - assert_eq!(refreshed.camera.position, update.camera.position); - assert_eq!(refreshed.camera.forward, update.camera.forward); - assert_eq!(refreshed.camera.right, update.camera.right); - assert_eq!(refreshed.camera.up, update.camera.up); - assert_eq!(refreshed.camera.position, expected_resized_camera.position); - assert_eq!(refreshed.camera.forward, expected_resized_camera.forward); - assert_eq!(refreshed.camera.right, expected_resized_camera.right); - assert_eq!(refreshed.camera.up, expected_resized_camera.up); - assert!( - (refreshed.camera.vertical_fov - expected_resized_camera.vertical_fov).abs() < 1.0e-6 + shadow_sphere_enclosing_current_geometry(small, large, &[]) + .expect("large current native sphere contains baseline"), + large ); - assert!(refreshed - .camera - .clip_from_world - .iter() - .zip(expected_resized_camera.clip_from_world) - .all(|(actual, expected)| (actual - expected).abs() < 1.0e-5)); - assert_ne!( - refreshed.camera.clip_from_world, - update.camera.clip_from_world - ); - assert_eq!(app.free_camera, Some(controller_after_input)); - let refreshed_environment_frame = - &refreshed.environment.as_ref().unwrap().environment_frame; assert_eq!( - refreshed_environment_frame.point_lights, - first_environment_frame.point_lights - ); - assert_eq!( - refreshed_environment_frame.sounds, - first_environment_frame.sounds + shadow_sphere_enclosing_current_geometry(large, small, &[]) + .expect("large baseline sphere contains current native sphere"), + large ); - let mut reprojected_counts = [0usize; 2]; - for (sampled, refreshed) in first_environment_frame - .primitives - .iter() - .zip(&refreshed_environment_frame.primitives) - { - let ( - EnvironmentPrimitive::Particle { - kind: sampled_kind, - position: sampled_position, - velocity: sampled_velocity, - size: sampled_size, - screen: sampled_screen, - .. - }, - EnvironmentPrimitive::Particle { - kind, - position, - velocity, - size, - screen, - .. - }, - ) = (sampled, refreshed) - else { - panic!("rain and snow fixture contains only precipitation particles"); - }; - assert_eq!(kind, sampled_kind); - assert_eq!(position, sampled_position); - assert_eq!(velocity, sampled_velocity); - assert_ne!(screen, sampled_screen); - assert_ne!(size, sampled_size); - let slot = match kind { - PrecipitationKind::Rain => 0, - PrecipitationKind::Snow => 1, - }; - reprojected_counts[slot] += 1; + } + + fn endpoint_op3_command() -> FxCommand { + let mut body = vec![0; 0xc4]; + write_effect_u32(&mut body, 0, 2); + write_effect_f32(&mut body, 0x04, 0.0); + write_effect_f32(&mut body, 0x08, 1.0); + write_effect_f32(&mut body, 0x0c, 1.0); + write_effect_f32(&mut body, 0x10, 0.0); + write_effect_f32(&mut body, 0x14, 1.0); + write_effect_f32(&mut body, 0x18, 1.0); + write_effect_f32(&mut body, 0x1c, 0.0); + write_effect_f32(&mut body, 0x20, 1.0); + write_effect_vec3(&mut body, 0x24, [0.0; 3]); + write_effect_vec3(&mut body, 0x30, [0.0; 3]); + write_effect_vec3(&mut body, 0x3c, [1.0; 3]); + write_effect_vec3(&mut body, 0x48, [0.0, 0.0, 1.0]); + write_effect_vec3(&mut body, 0x54, [0.0, 0.0, 1.0]); + write_effect_vec3(&mut body, 0x60, [1.0; 3]); + write_effect_vec3(&mut body, 0x6c, [1.0; 3]); + write_effect_vec3(&mut body, 0x78, [1.0; 3]); + body[0x84..0x84 + 11].copy_from_slice(b"effects.rlb"); + let name = b"f_smalllight_y"; + body[0xa4..0xa4 + name.len()].copy_from_slice(name); + FxCommand { + word: 3, + opcode: FxOpcode::Op3, + native_flag: false, + raw_body: Arc::from(body), + resource_refs: Vec::new(), } - assert!(reprojected_counts.iter().all(|count| *count > 0)); - assert_eq!( - app.apply_pending_scene_update().unwrap_err(), - "native Vulkan renderer was not initialized" - ); - let restored = app - .lifecycle - .pending - .as_ref() - .expect("restored pending sample"); - assert_eq!(restored.sample, sample); - assert_eq!(restored.material_update, world_material_packet); - assert_eq!(restored.environment_material_update, sky_material_packet); - assert_eq!(app.material_animations[0].base.random_state, 2_531_011); - assert_eq!( - app.environment_gpu.as_ref().unwrap().material_animations[0] - .base - .random_state, - 2_531_011 - ); - assert_eq!(app.mesh.vertices, b_vertices); - assert_eq!(app.shadow_casters[0].triangles, b_triangles); - assert_eq!( - app.control_animation_instances[0].state, - control_state_after_tick - ); - assert_eq!(app.material_time_ms, sample.time_ms); - assert_eq!(app.simulation_elapsed, Duration::from_millis(225)); - assert_eq!(app.lifecycle.frames_presented, 0); - assert_eq!(app.lifecycle.record_presented(), Some(0)); + } - app.update_controlled_component_geometry(sample) - .expect("repeat the same control timestamp"); - assert_eq!(app.mesh.vertices, b_vertices); - assert_eq!(app.shadow_casters[0].triangles, b_triangles); - assert_eq!(app.shadow_casters[0].sphere, b_sphere); + fn endpoint_op1_command(effect_type: u32, start: f32, end: f32) -> FxCommand { + let mut body = vec![0; 132]; + write_effect_u32(&mut body, 0, effect_type); + write_effect_f32(&mut body, 4, start); + write_effect_f32(&mut body, 8, end); + write_effect_vec3(&mut body, 12, [1.0, 0.0, 0.0]); + write_effect_vec3(&mut body, 24, [3.0, 0.0, 0.0]); + write_effect_vec3(&mut body, 36, [0.0, 1.0, 0.0]); + write_effect_vec3(&mut body, 48, [0.0, 1.0, 0.0]); + for (index, value) in [0.0, 1.0, 0.5, 1.0].into_iter().enumerate() { + write_effect_f32(&mut body, 60 + index * 4, value); + } + for (index, value) in [0.5, 0.0, 1.0, 1.0].into_iter().enumerate() { + write_effect_f32(&mut body, 76 + index * 4, value); + } + write_effect_f32(&mut body, 108, 10.0); + write_effect_f32(&mut body, 112, 20.0); + write_effect_f32(&mut body, 116, 1.0); + write_effect_vec3(&mut body, 120, [1.0, 0.5, 0.25]); + FxCommand { + word: 1, + opcode: FxOpcode::Op1, + native_flag: false, + raw_body: Arc::from(body), + resource_refs: Vec::new(), + } + } - let first_sample = FrameClockSample { - id: SceneSampleId::Live(0), - time_ms: 0, - delta_seconds: 0.0, + fn endpoint_gener_ball_command(opcode: FxOpcode) -> FxCommand { + let is_op9 = opcode == FxOpcode::Op9; + let mut body = vec![0; if is_op9 { 0xcc } else { 0xc8 }]; + write_effect_u32(&mut body, 0, if is_op9 { 2 } else { 0 }); + write_effect_f32(&mut body, 0x04, 0.0); + write_effect_f32(&mut body, 0x08, 1.0); + write_effect_f32(&mut body, 0x0c, 1.0); + write_effect_f32(&mut body, 0x10, 0.0); + write_effect_f32(&mut body, 0x14, 1.0); + write_effect_f32(&mut body, 0x18, 1.0); + write_effect_f32(&mut body, 0x1c, 0.75); + write_effect_f32(&mut body, 0x20, 1.0); + write_effect_vec3(&mut body, 0x24, [0.0; 3]); + write_effect_vec3(&mut body, 0x30, [0.0; 3]); + write_effect_vec3(&mut body, 0x3c, [1.0; 3]); + write_effect_vec3(&mut body, 0x48, [0.0, 1.0, 0.0]); + write_effect_vec3(&mut body, 0x54, [0.0, 1.0, 0.0]); + write_effect_vec3(&mut body, 0x60, [1.0; 3]); + write_effect_vec3(&mut body, 0x6c, [1.0; 3]); + write_effect_vec3(&mut body, 0x78, [1.0; 3]); + body[0x84..0x84 + 12].copy_from_slice(b"material.lib"); + let name = if is_op9 { + b"laser_y_hit".as_slice() + } else { + b"glow_y" }; - app.update_material_clock(first_sample); - app.update_controlled_component_geometry(first_sample) - .expect("return to first control pose"); - assert_eq!(app.mesh.vertices, initial_mesh.vertices); - assert_eq!(app.shadow_casters[0].triangles, initial_triangles); - assert_eq!(app.shadow_casters[0].sphere, baseline); - - let next_sample = app.sample_live_frame_clock_at(start + Duration::from_millis(241)); - assert_eq!(next_sample.id, SceneSampleId::Live(1)); - assert_eq!(next_sample.time_ms, 241); - let next_update = app - .prepare_scene_update(next_sample, [800.0, 600.0]) - .expect("the next sampled frame adopts mouse input received during retry"); - assert_ne!(next_update.camera.forward, update.camera.forward); - assert_eq!(next_update.camera.forward, controller_after_input.forward()); + body[0xa4..0xa4 + name.len()].copy_from_slice(name); + if is_op9 { + write_effect_u32(&mut body, 0xc4, 0); + } else { + write_effect_f32(&mut body, 0xc4, 500.0); + } + FxCommand { + word: if is_op9 { 9 } else { 4 }, + opcode, + native_flag: false, + raw_body: Arc::from(body), + resource_refs: Vec::new(), + } } - fn set_test_terrain_lod0_range(terrain: &mut fparkan_terrain_format::LandMeshDocument) { - const NODE_STRIDE: usize = 38; - const SLOT_STRIDE: usize = 68; - let mut node = vec![u8::MAX; NODE_STRIDE]; - node[8..10].copy_from_slice(&0_u16.to_le_bytes()); - node[10..12].copy_from_slice(&u16::MAX.to_le_bytes()); - let mut slot = [0_u8; SLOT_STRIDE]; - slot[..2].copy_from_slice(&0_u16.to_le_bytes()); - let face_count = u16::try_from(terrain.faces.len()).expect("test face count fits"); - slot[2..4].copy_from_slice(&face_count.to_le_bytes()); - terrain.nodes_raw = node; - terrain.slots.slots_raw = vec![slot]; + fn endpoint_op8_command(pool_count: u32) -> FxCommand { + let mut body = vec![0; 0xf4]; + write_effect_u32(&mut body, 0x20, pool_count); + FxCommand { + word: 8, + opcode: FxOpcode::Op8, + native_flag: false, + raw_body: Arc::from(body), + resource_refs: Vec::new(), + } } - fn strings(values: &[&str]) -> Vec<String> { - values.iter().map(|value| (*value).to_string()).collect() + fn endpoint_op7_command( + mode: u32, + material_phase_factor: f32, + acceleration: f32, + pool_count: u32, + copies_per_group: u32, + ) -> FxCommand { + let mut body = vec![0; 0xCC]; + write_effect_u32(&mut body, 0x00, mode); + write_effect_f32(&mut body, 0x1C, material_phase_factor); + write_effect_u32(&mut body, 0x20, pool_count); + write_effect_u32(&mut body, 0x24, copies_per_group); + write_effect_f32(&mut body, 0x88, acceleration); + FxCommand { + word: 7, + opcode: FxOpcode::Op7, + native_flag: false, + raw_body: Arc::from(body), + resource_refs: Vec::new(), + } } - fn scene_frame_update(sample: FrameClockSample, viewport: [f32; 2]) -> SceneFrameUpdate { - let aspect = viewport[0] / viewport[1]; - let camera = FreeFlightCamera { - position: [0.0; 3], - yaw: 0.0, - pitch: 0.0, - horizontal_fov: 1.0, - near_plane: 0.1, - far_plane: 100.0, - move_speed: 1.0, - ground_clearance: 0.0, - } - .preview_frame(aspect); - SceneFrameUpdate { - sample, - camera, - viewport, - aspect, - material_update: MaterialFrameUpdate::default(), - environment_material_update: None, - environment: None, + fn endpoint_effect_frame(command_index: usize) -> fparkan_fx::fx_visual::EffectQuadFrame { + fparkan_fx::fx_visual::EffectQuadFrame { + command_index, + opcode: FxOpcode::Op7, + material: fparkan_fx::environment::EffectResource { + archive: "material.lib".to_owned(), + name: "smoke_yg".to_owned(), + }, + vertices: [[0.0; 3]; 4], + uv: [[0.0; 2]; 4], + color: [1.0; 4], + material_phase: 0.25, + elapsed_seconds: 0.5, + normalized_time: 0.5, + active: true, + native_flag_base: None, } } + fn strings(values: &[&str]) -> Vec<String> { + values.iter().map(|value| (*value).to_string()).collect() + } + #[test] #[ignore = "requires licensed corpus"] fn licensed_corpus_part1_prepares_every_mission() { @@ -8177,9 +13066,12 @@ mod tests { }; let mut bindings = vec![MaterialAnimationBinding { initial_phase_index: 0, + normalized_phase_override: None, + ambient_fx_material_alpha: false, variants: vec![MaterialVariantSet { phase_material_indices: vec![10], range_indices: vec![0, 2], + pipeline_category: 0, }], base: empty_binding(), detail: None, @@ -8236,10 +13128,42 @@ mod tests { } #[test] + fn cpu_msh_portal_alpha_updates_with_distance_and_preserves_type16() { + let mut mesh = VulkanStaticMesh::smoke_triangle(); + for flags in [0x0148, 0x0108, 0x0118, 0x0058] { + mesh.draw_ranges[0].batch_flags = flags; + assert_eq!( + native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -1.0], 1.04), + Ok(Some(0.0)) + ); + assert_eq!( + native_msh_diffuse_alpha_override(&mesh, 0, true, [0.0, -0.55, -1.0], 1.04), + Ok(None) + ); + } + mesh.draw_ranges[0].batch_flags = 0x0118; + let alpha = native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -25.0], 1.04) + .unwrap() + .unwrap(); + assert!((alpha - 0.5).abs() < 0.000_001); + assert_eq!( + native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -60.0], 1.04), + Ok(Some(1.0)) + ); + assert!(native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0; 3], 0.0).is_err()); + mesh.draw_ranges[0].batch_flags = 0x0040; + assert_eq!( + native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0; 3], 1.04), + Ok(None) + ); + } + + #[test] fn material_animation_selects_pipeline_category_with_active_phase() { let variant = MaterialVariantSet { phase_material_indices: vec![10, 11], range_indices: vec![3], + pipeline_category: 1, }; let category_for = |material_index| match material_index { 10 => Some(1), @@ -8314,36 +13238,33 @@ mod tests { } #[test] - fn animated_shadow_bounds_rebuild_from_native_baseline() { - let baseline = ShadowSphere { - center: [0.0; 3], - radius: 1.0, - }; - let pose_a = [shadow_triangle( - [[0.5, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.5]], - 0, - )]; - let pose_b = [shadow_triangle( - [[3.0, 0.0, 0.0], [4.0, 0.0, 0.0], [3.0, 1.0, 0.0]], - 0, - )]; - - let first_a = - shadow_sphere_enclosing_triangles(baseline, &pose_a).expect("initial animated bounds"); - assert_eq!(first_a, baseline); - let at_b = - shadow_sphere_enclosing_triangles(baseline, &pose_b).expect("expanded animated bounds"); - assert!((at_b.center[0] - 1.5).abs() < 1.0e-5); - assert!((at_b.radius - 2.5).abs() < 1.0e-5); - assert!(length3(at_b.center).expect("finite sphere center") + 1.0 <= at_b.radius); - for point in pose_b.iter().flat_map(|triangle| triangle.positions) { - assert!( - length3(sub3(point, at_b.center)).expect("finite point distance") <= at_b.radius - ); - } - let second_a = shadow_sphere_enclosing_triangles(baseline, &pose_a) - .expect("return to first animated pose"); - assert_eq!(second_a, first_a); + fn projected_shadow_alone_uses_decal_depth_bias() { + let shadow = projected_shadow_pipeline_state(); + assert_eq!(shadow.blend, LegacyBlendMode::SourceAlpha); + assert_eq!(shadow.depth, LegacyDepthMode::TestReadOnly); + assert_eq!(shadow.depth_bias, LegacyDepthBias::ProjectedDecal); + assert_eq!( + world_pipeline_state(LegacyBlendMode::Opaque, LegacyDepthMode::TestWrite).depth_bias, + LegacyDepthBias::Disabled + ); + } + + #[test] + fn shadow_atlas_texture_equality_includes_pixels_and_mip_layout() { + let uploaded = shadow_atlas_texture(); + assert_eq!(shadow_atlas_texture(), uploaded); + + let mut changed_rgba = uploaded.clone(); + changed_rgba.rgba8[0] ^= 1; + assert_ne!(changed_rgba, uploaded); + + let mut changed_mip_pixels = uploaded.clone(); + changed_mip_pixels.mip_levels[0].rgba8[0] ^= 1; + assert_ne!(changed_mip_pixels, uploaded); + + let mut changed_mip = uploaded.clone(); + changed_mip.mip_levels[0].width -= 1; + assert_ne!(changed_mip, uploaded); } #[test] @@ -8416,6 +13337,7 @@ mod tests { control_clock_ms: None, validation: false, readback_out: None, + readback_sequence_out: None, preview_roots: NonZeroUsize::MAX, legacy_camera_capture: None, static_animation_frame: None, @@ -8440,44 +13362,810 @@ mod tests { } #[test] - fn replay_clock_stays_on_one_sample_until_presented() { - let mut lifecycle = SceneFrameLifecycle::default(); - let first = lifecycle.sample_replay(500, 101); - assert_eq!(first.id, SceneSampleId::Replay(0)); - assert_eq!(first.time_ms, 0); - assert_eq!(first.delta_seconds, 0.0); - let update = scene_frame_update(first, [1280.0, 720.0]); - assert!(lifecycle.prepare(update.clone())); - - // A swapchain recreation retries the same packet and does not count a frame. - assert_eq!(lifecycle.sample_replay(500, 101), first); - assert_eq!(lifecycle.frames_presented, 0); - assert_eq!(lifecycle.pending.as_ref(), Some(&update)); - assert_eq!(lifecycle.record_presented(), Some(0)); - - let second = lifecycle.sample_replay(500, 101); - assert_eq!(second.id, SceneSampleId::Replay(1)); - assert_eq!(second.time_ms, 5); - assert!((second.delta_seconds - 0.005).abs() < f32::EPSILON); + fn control_clock_replays_one_shared_clock_from_zero_to_target() { + assert_eq!(control_clock_ms_for_frame(500, 0, 101), 0); + assert_eq!(control_clock_ms_for_frame(500, 50, 101), 250); + assert_eq!(control_clock_ms_for_frame(500, 100, 101), 500); + assert_eq!(control_clock_ms_for_frame(500, 1000, 101), 500); + assert_eq!(control_clock_ms_for_frame(500, 0, 1), 500); + assert_eq!(control_clock_mode_label(Some(0)), "fixed-0ms"); + assert_eq!(control_clock_mode_label(Some(500)), "ramp-0-to-500ms"); + assert_eq!(control_clock_mode_label(None), "realtime"); + } + + fn clock_test_app( + control_clock_target_ms: Option<u32>, + ) -> (StaticVulkanApp, ControlEffectInstanceKey) { + let phase = |additive_red| MaterialPhase { + parameters: [ + additive_red, + 0, + 0, + 100, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + texture_raw: [0; 16], + }; + let document = Mat0Document { + version: 0, + animation_block_count: 1, + phases: vec![phase(0), phase(255)], + prefix: Vec::new(), + header_opaque: [0; 2], + animation_blocks: vec![MaterialAnimationBlock { + header_raw: 0, + keys: vec![ + MaterialKey { + k0: 0, + k1: 500, + k2: 0, + }, + MaterialKey { + k0: 1, + k1: 1000, + k2: 0, + }, + ], + bytes: Vec::new(), + }], + }; + let material_animation = MaterialAnimationBinding { + initial_phase_index: 0, + normalized_phase_override: None, + ambient_fx_material_alpha: false, + variants: vec![MaterialVariantSet { + phase_material_indices: vec![0, 1], + range_indices: vec![0], + pipeline_category: 0, + }], + base: MaterialPhaseBinding { + document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: 0, + frozen_phase_index: None, + phase_uv_transforms: vec![[0.0, 0.0, 1.0, 1.0]; 2], + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }; + let mut materials = Vec::new(); + append_environment_gradient_material(&mut materials) + .expect("first animated material variant"); + append_environment_gradient_material(&mut materials) + .expect("second animated material variant"); + + let key = ControlEffectInstanceKey { + object_index: 0, + component_index: 0, + table_index: 0, + row_index: 0, + }; + let control_slot = ControlPointLightSlot { + time_mode: 1, + header_flags: 9, + random_instance_key: key, + random_seed: 0x1234_5678, + phase_jitter: 0.25, + random_shift: [2.0, 3.0, 4.0], + duration_seconds: 1.0, + owner_object_index: 0, + owner_component_index: 0, + owner_node_index: 0, + owner_component_is_mounted: false, + root_transform: LegacyIron3dEulerTransform { + translation: [10.0, 20.0, 30.0], + orientation_radians: [0.0; 3], + }, + owner_scale: [1.0; 3], + mount_pose: None, + local_from_effect: [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 2.0], + [0.0, 0.0, 1.0, 3.0], + [0.0, 0.0, 0.0, 1.0], + ], + world_from_effect: [[0.0; 4]; 4], + command_index: 0, + command: endpoint_op1_command(6, 0.5, 1.0), + }; + + let sky_materials = SkyMaterials::default(); + let sky = + SkySystem::new(Default::default(), sky_materials.clone()).expect("test sky topology"); + let environment = DynamicEnvironment { + schedule: TypedAtmosphere { + marker: 0, + version: 0, + tracks: Vec::new(), + trailer: Default::default(), + }, + sky, + environment: EnvironmentSystem::new(0x4650_4152_4B41_4E), + materials: sky_materials, + material_assets: HashMap::new(), + schedule_offset_seconds: 0.0, + time_seconds: 0.0, + fixed_schedule_seconds: None, + fixed_phase_reported: false, + }; + + let mut land = fparkan_terrain_format::LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: fparkan_terrain_format::TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]], + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![fparkan_terrain_format::TerrainFace28 { + flags: FullSurfaceMask(1), + material_tag: 0, + aux_tag: 0, + vertices: [0, 1, 2], + neighbors: [None, None, None], + tail_raw: [0; 8], + raw: [0; 28], + }], + }; + set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]); + let terrain = + Arc::new(TerrainWorld::from_land_msh(&land).expect("clock test LOD0 terrain")); + let free_camera = FreeFlightCamera { + position: [2.0, 2.0, 5.0], + yaw: 0.25, + pitch: -0.1, + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: 4.0, + ground_clearance: 0.0, + }; + let view_camera = free_camera.preview_frame(16.0 / 9.0); + let preview = StaticPreviewScene { + mesh: VulkanStaticMesh { + vertices: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + .into_iter() + .map(|position| VulkanStaticVertex { + position, + color: [1.0; 3], + normal: [0.0, 0.0, 1.0], + uv: [0.0; 2], + detail_uv: [0.0; 2], + overlay_alpha: 0.0, + }) + .collect(), + indices: vec![0, 1, 2], + draw_ranges: vec![VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + terrain_base_only: false, + pipeline_state: world_pipeline_state( + LegacyBlendMode::Opaque, + LegacyDepthMode::TestReadOnly, + ), + alpha_test_reference: 0, + }], + }, + native_handedness_by_range: HashMap::new(), + type16_present_ranges: HashSet::new(), + materials, + material_animations: vec![material_animation], + ambient_effect_slots: Vec::new(), + control_point_light_slots: vec![control_slot], + component_pose_buffers: vec![vec![None]], + component_mount_poses: vec![vec![None]], + dynamic_component_geometry: Vec::new(), + building_collision_bindings: Vec::new(), + control_animation_instances: Vec::new(), + dynamic_shadow_casters: Vec::new(), + sun_occlusion_range_indices: Vec::new(), + shadow_casters: Vec::new(), + shadow_receivers: Vec::new(), + shadow_material_index: 0, + terrain: terrain.clone(), + camera: view_camera.vulkan_camera(), + view_camera, + captured_viewport: None, + free_camera: Some(free_camera), + camera_mode: "test", + mesh_components: 0, + terrain_components: 0, + }; + let app = StaticVulkanApp::new( + preview, + Some(environment), + None, + EnvironmentGpuRange { + range_index: 1, + vertex_start: 3, + vertex_capacity: 0, + material_index: 0, + }, + 3, + 3, + None, + 2, + "clock-test", + 1, + None, + None, + false, + control_clock_target_ms, + StartupTimings::default(), + ); + (app, key) + } + + fn control_random_snapshot( + state: &ControlEffectRandomState, + ) -> ( + u32, + u32, + ControlEffectRandomSample, + Option<SceneSampleId>, + Option<u32>, + ) { + ( + state.phase_and_z_rng.state, + state.xy_rng.state, + state.sample, + state.last_update_id, + state.last_update_clock_ms, + ) + } + + #[test] + fn live_clock_samples_have_increasing_ids_and_monotonic_time() { + let (mut app, _) = clock_test_app(None); + let first = app.sample_frame_clock(); + let second = app.sample_frame_clock(); + assert_eq!(first.id, SceneSampleId::Live(0)); + assert_eq!(second.id, SceneSampleId::Live(1)); + assert!(second.time_ms >= first.time_ms); + assert!(second.delta_seconds >= 0.0); } #[test] - fn replay_clock_requires_at_least_two_requested_frames() { - for frame_count in ["0", "1"] { - assert!(Args::parse(&strings(&[ - "--root", - "testdata/IS", - "--mission", - "MISSIONS/Autodemo.00/data.tma", - "--frames", - frame_count, - "--control-clock-ms", - "500", - ])) - .unwrap_err() - .contains("--frames of at least 2")); + fn zero_extent_resume_does_not_add_minimized_time_to_live_clock() { + let (mut app, _) = clock_test_app(None); + let started = Instant::now(); + app.simulation_last_tick = started; + app.pause_live_clock_at(started); + assert!(app.live_clock_paused_for_zero_extent); + + let restored = started + Duration::from_secs(600); + app.resume_live_clock_at(restored); + assert!(!app.live_clock_paused_for_zero_extent); + assert_eq!(app.simulation_last_tick, restored); + + let resumed = app.sample_live_frame_clock_at(restored + Duration::from_millis(16)); + assert_eq!(resumed.id, SceneSampleId::Live(0)); + assert_eq!(resumed.time_ms, 16); + assert!((resumed.delta_seconds - 0.016).abs() < 1.0e-6); + assert_eq!(app.simulation_elapsed, Duration::from_millis(16)); + } + + #[test] + fn live_cpu_updates_sample_materials_effect_rng_and_environment_without_renderer() { + let (mut app, key) = clock_test_app(None); + app.render_scene_update() + .expect("an early redraw without a packet is a no-op"); + assert!(app.pending_scene_update.is_none()); + assert_eq!(app.frames_presented, 0); + assert_eq!(app.simulation_update_generation, 0); + + let mut first = app.sample_frame_clock(); + assert_eq!(first.id, SceneSampleId::Live(0)); + first.time_ms = 0; + first.delta_seconds = 0.1; + app.prepare_scene_update(first, [1280.0, 720.0]) + .expect("prepare a live CPU frame without a renderer"); + assert!(app.renderer.is_none()); + let first_frame = app.pending_scene_update.as_ref().expect("sampled frame"); + assert_eq!( + first_frame.material_update.active_materials[&0].phase_index, + 0 + ); + assert!(!first_frame + .environment + .as_ref() + .unwrap() + .sky_mesh + .vertices + .is_empty()); + let first_random = control_random_snapshot(&app.control_effect_random_states[&key]); + assert_eq!(first_random.3, Some(SceneSampleId::Live(0))); + assert_ne!(first_random.0, 0); + assert_ne!(first_random.1, 0); + let first_environment_time = app.environment.as_ref().unwrap().time_seconds; + assert!(first_environment_time > 0.0); + let first_vertices = app.mesh.vertices.clone(); + + for _ in 0..2 { + app.render_scene_update() + .expect("renderer-free redraw does not sample simulation state"); } + assert_eq!( + control_random_snapshot(&app.control_effect_random_states[&key]), + first_random + ); + assert_eq!( + app.environment.as_ref().unwrap().time_seconds, + first_environment_time + ); + assert_eq!(app.mesh.vertices, first_vertices); + assert_eq!(app.simulation_update_generation, 1); + + let mut next = app.sample_frame_clock(); + assert_eq!(next.id, SceneSampleId::Live(1)); + next.time_ms = 500; + next.delta_seconds = 0.5; + app.prepare_scene_update(next, [1280.0, 720.0]) + .expect("replace the latest live packet"); + assert_eq!(app.simulation_update_generation, 2); + assert_eq!( + app.pending_scene_update.as_ref().unwrap().clock.id, + SceneSampleId::Live(1) + ); + assert_ne!( + control_random_snapshot(&app.control_effect_random_states[&key]), + first_random + ); + assert!(app.environment.as_ref().unwrap().time_seconds > first_environment_time); + assert_eq!( + app.pending_scene_update + .as_ref() + .unwrap() + .material_update + .active_materials[&0] + .phase_index, + 1 + ); + assert_eq!( + app.pending_scene_update.as_ref().unwrap().clock.time_ms, + 500 + ); + } + #[test] + fn live_pending_resize_reuses_225ms_packet_across_zero_extent_pause() { + let (mut app, key) = clock_test_app(None); + let started = Instant::now(); + app.simulation_last_tick = started; + app.pressed_keys.insert(KeyCode::KeyE); + + let first = app.sample_live_frame_clock_at(started + Duration::from_millis(225)); + assert_eq!(first.id, SceneSampleId::Live(0)); + assert_eq!(first.time_ms, 225); + assert!((first.delta_seconds - 0.225).abs() < 1.0e-6); + app.prepare_scene_update(first, [1280.0, 720.0]) + .expect("sample one 225 ms live packet"); + let first_frame = app.pending_scene_update.as_ref().unwrap(); + let first_camera = first_frame.camera; + let expected_resized_camera = app + .free_camera + .expect("free-flight input controller") + .preview_frame(4.0 / 3.0); + let first_effect_updates = first_frame.effect_updates.clone(); + let first_material_phase = first_frame.material_update.active_materials[&0].phase_index; + let weather = WeatherInput { + rain: WeatherState::new(true, 0.03, [0.7, 0.8, 1.0, 0.6]), + snow: WeatherState::new(true, 0.03, [0.9, 0.95, 1.0, 0.8]), + ..WeatherInput::clear() + }; + let mut weather_system = EnvironmentSystem::new(0xC1_225); + let first_environment_frame = weather_system.update( + 0.225, + weather, + environment_camera_for_preview(&first_camera, 16.0 / 9.0, [1280.0, 720.0]), + ); + let mut precipitation_counts = [0usize; 2]; + for primitive in &first_environment_frame.primitives { + if let EnvironmentPrimitive::Particle { kind, .. } = primitive { + let slot = match kind { + PrecipitationKind::Rain => 0, + PrecipitationKind::Snow => 1, + }; + precipitation_counts[slot] += 1; + } + } + assert!(precipitation_counts.iter().all(|count| *count >= 17)); + app.pending_scene_update + .as_mut() + .unwrap() + .environment + .as_mut() + .unwrap() + .environment_frame = first_environment_frame.clone(); + let first_random = control_random_snapshot(&app.control_effect_random_states[&key]); + let first_environment_time = app.environment.as_ref().unwrap().time_seconds; + let first_environment_system_time = + app.environment.as_ref().unwrap().environment.time_seconds(); + let first_vertices = app.mesh.vertices.clone(); + assert!((first_camera.position[2] - 5.4).abs() < 1.0e-5); + assert_eq!(app.simulation_elapsed, Duration::from_millis(225)); + assert_eq!(app.simulation_update_generation, 1); + + app.pause_live_clock_at(started + Duration::from_millis(250)); + let restored = started + Duration::from_secs(10); + app.resume_live_clock_at(restored); + app.free_camera.as_mut().unwrap().look_delta(64.0, -24.0); + let controller_after_input = app.free_camera.unwrap(); + let retry = app.pending_or_sample_frame_clock(); + assert_eq!(retry, first); + assert_eq!(app.simulation_update_generation, 1); + assert_eq!(app.simulation_elapsed, Duration::from_millis(225)); + assert_eq!(app.simulation_last_tick, restored); + + app.prepare_scene_update(retry, [1024.0, 768.0]) + .expect("refresh only the pending packet's viewport"); + let retry_frame = app.pending_scene_update.as_ref().unwrap(); + assert_eq!(retry_frame.clock, first); + assert_eq!(retry_frame.viewport, [1024.0, 768.0]); + assert_eq!(retry_frame.aspect, 4.0 / 3.0); + assert_eq!(retry_frame.camera.position, first_camera.position); + assert_eq!(retry_frame.camera.forward, first_camera.forward); + assert_eq!(retry_frame.camera.right, first_camera.right); + assert_eq!(retry_frame.camera.up, first_camera.up); + assert_eq!( + retry_frame.camera.position, + expected_resized_camera.position + ); + assert_eq!(retry_frame.camera.forward, expected_resized_camera.forward); + assert_eq!(retry_frame.camera.right, expected_resized_camera.right); + assert_eq!(retry_frame.camera.up, expected_resized_camera.up); + assert!( + (retry_frame.camera.vertical_fov - expected_resized_camera.vertical_fov).abs() < 1.0e-6 + ); + assert!(retry_frame + .camera + .clip_from_world + .iter() + .zip(expected_resized_camera.clip_from_world) + .all(|(actual, expected)| (actual - expected).abs() < 1.0e-5)); + assert_ne!( + retry_frame.camera.clip_from_world, + first_camera.clip_from_world + ); + assert_eq!(app.free_camera, Some(controller_after_input)); + assert_eq!(retry_frame.effect_updates, first_effect_updates); + assert_eq!( + retry_frame.material_update.active_materials[&0].phase_index, + first_material_phase + ); + let retry_environment_frame = &retry_frame.environment.as_ref().unwrap().environment_frame; + assert_eq!( + retry_environment_frame.point_lights, + first_environment_frame.point_lights + ); + assert_eq!( + retry_environment_frame.sounds, + first_environment_frame.sounds + ); + let mut reprojected_counts = [0usize; 2]; + for (sampled, refreshed) in first_environment_frame + .primitives + .iter() + .zip(&retry_environment_frame.primitives) + { + let ( + EnvironmentPrimitive::Particle { + kind: sampled_kind, + position: sampled_position, + velocity: sampled_velocity, + size: sampled_size, + screen: sampled_screen, + .. + }, + EnvironmentPrimitive::Particle { + kind, + position, + velocity, + size, + screen, + .. + }, + ) = (sampled, refreshed) + else { + panic!("rain and snow fixture contains only precipitation particles"); + }; + assert_eq!(kind, sampled_kind); + assert_eq!(position, sampled_position); + assert_eq!(velocity, sampled_velocity); + assert_ne!(screen, sampled_screen); + let slot = match kind { + PrecipitationKind::Rain => 0, + PrecipitationKind::Snow => 1, + }; + reprojected_counts[slot] += 1; + assert_ne!(size, sampled_size); + } + assert!(reprojected_counts.iter().all(|count| *count >= 17)); + assert_eq!( + control_random_snapshot(&app.control_effect_random_states[&key]), + first_random + ); + assert_eq!( + app.environment.as_ref().unwrap().time_seconds, + first_environment_time + ); + assert_eq!( + app.environment.as_ref().unwrap().environment.time_seconds(), + first_environment_system_time + ); + assert_eq!(app.mesh.vertices, first_vertices); + + assert_eq!(app.record_presented_scene_frame(), 0); + let next = app.sample_live_frame_clock_at(restored + Duration::from_millis(16)); + assert_eq!(next.id, SceneSampleId::Live(1)); + assert_eq!(next.time_ms, 241); + assert!((next.delta_seconds - 0.016).abs() < 1.0e-6); + assert_eq!(app.simulation_elapsed, Duration::from_millis(241)); + app.prepare_scene_update(next, [1024.0, 768.0]) + .expect("the next sample adopts mouse input accumulated during retry"); + let next_camera = app.pending_scene_update.as_ref().unwrap().camera; + assert_ne!(next_camera.forward, first_camera.forward); + assert_eq!(next_camera.forward, controller_after_input.forward()); + } + + #[test] + fn replay_retries_keep_one_packet_until_presented_then_advance_with_new_viewport() { + let (mut app, key) = clock_test_app(Some(500)); + app.render_scene_update() + .expect("redraw without a pending packet does nothing"); + assert_eq!(app.simulation_update_generation, 0); + + let first = app.sample_frame_clock(); + assert_eq!( + first, + FrameClockSample { + id: SceneSampleId::Replay(0), + time_ms: 0, + delta_seconds: 0.0, + } + ); + app.prepare_scene_update(first, [1280.0, 720.0]) + .expect("prepare the first replay frame"); + let first_frame = app.pending_scene_update.as_ref().unwrap(); + let first_camera = first_frame.camera; + let first_viewport = first_frame.viewport; + let first_aspect = first_frame.aspect; + let first_effect_updates = first_frame.effect_updates.clone(); + let first_environment_mesh = first_frame.environment.as_ref().unwrap().sky_mesh.clone(); + let first_environment_frame = first_frame + .environment + .as_ref() + .unwrap() + .environment_frame + .clone(); + let first_material_phase = first_frame.material_update.active_materials[&0].phase_index; + let first_environment_time = app.environment.as_ref().unwrap().time_seconds; + let first_random = control_random_snapshot(&app.control_effect_random_states[&key]); + let first_vertices = app.mesh.vertices.clone(); + assert_eq!(first_material_phase, 0); + assert_eq!(first_random.3, Some(SceneSampleId::Replay(0))); + assert_eq!(first_random.4, Some(0)); + assert!(!first_environment_mesh.vertices.is_empty()); + + for _ in 0..2 { + app.render_scene_update() + .expect("renderer-free replay redraw is a retry"); + let retry = app.sample_frame_clock(); + assert_eq!(retry.id, SceneSampleId::Replay(0)); + app.prepare_scene_update(retry, [1024.0, 768.0]) + .expect("a resize retry reuses the current replay packet"); + } + let retry_frame = app.pending_scene_update.as_ref().unwrap(); + assert_eq!(retry_frame.camera.position, first_camera.position); + assert_eq!(retry_frame.camera.forward, first_camera.forward); + assert_eq!(retry_frame.camera.up, first_camera.up); + assert_eq!(retry_frame.viewport, [1024.0, 768.0]); + assert_eq!(retry_frame.aspect, 4.0 / 3.0); + assert_ne!( + retry_frame.camera.clip_from_world, + first_camera.clip_from_world + ); + assert_ne!(retry_frame.viewport, first_viewport); + assert_ne!(retry_frame.aspect, first_aspect); + assert_eq!(retry_frame.effect_updates, first_effect_updates); + assert_eq!( + retry_frame.environment.as_ref().unwrap().sky_mesh, + first_environment_mesh + ); + assert_eq!( + retry_frame.environment.as_ref().unwrap().environment_frame, + first_environment_frame + ); + assert_eq!( + retry_frame.material_update.active_materials[&0].phase_index, + 0 + ); + assert_eq!( + control_random_snapshot(&app.control_effect_random_states[&key]), + first_random + ); + assert_eq!( + app.environment.as_ref().unwrap().time_seconds, + first_environment_time + ); + assert_eq!(app.mesh.vertices, first_vertices); + assert_eq!(app.frames_presented, 0); + + assert_eq!(app.record_presented_scene_frame(), 0); + assert_eq!(app.frames_presented, 1); + assert!(app.pending_scene_update.is_none()); + let next = app.sample_frame_clock(); + assert_eq!(next.id, SceneSampleId::Replay(1)); + assert_eq!(next.time_ms, 500); + assert_eq!(next.delta_seconds, 0.5); + app.prepare_scene_update(next, [1024.0, 768.0]) + .expect("the next presented replay frame samples the resized view"); + let next_frame = app.pending_scene_update.as_ref().unwrap(); + assert_eq!(next_frame.viewport, [1024.0, 768.0]); + assert_eq!(next_frame.aspect, 4.0 / 3.0); + assert_ne!( + next_frame.camera.clip_from_world, + first_camera.clip_from_world + ); + assert_eq!( + next_frame.material_update.active_materials[&0].phase_index, + 1 + ); + assert!( + (app.environment.as_ref().unwrap().time_seconds - first_environment_time - 0.5).abs() + < 1.0e-6 + ); + let next_random = control_random_snapshot(&app.control_effect_random_states[&key]); + assert_eq!(next_random.3, Some(SceneSampleId::Replay(1))); + assert_eq!(next_random.4, Some(500)); + assert_ne!(next_random, first_random); + let next_camera = next_frame.camera; + let next_environment_mesh = next_frame.environment.as_ref().unwrap().sky_mesh.clone(); + let next_environment_frame = next_frame + .environment + .as_ref() + .unwrap() + .environment_frame + .clone(); + + app.render_scene_update() + .expect("renderer-free draw does not advance the replay packet"); + let repeated = app.sample_frame_clock(); + assert_eq!(repeated.id, SceneSampleId::Replay(1)); + app.prepare_scene_update(repeated, [800.0, 600.0]) + .expect("duplicate replay frame is idempotent"); + let repeated_frame = app.pending_scene_update.as_ref().unwrap(); + assert_eq!(repeated_frame.viewport, [800.0, 600.0]); + assert_eq!(repeated_frame.aspect, 4.0 / 3.0); + assert_eq!(repeated_frame.camera, next_camera); + assert_eq!( + repeated_frame.environment.as_ref().unwrap().sky_mesh, + next_environment_mesh + ); + assert_eq!( + repeated_frame + .environment + .as_ref() + .unwrap() + .environment_frame, + next_environment_frame + ); + assert_eq!( + control_random_snapshot(&app.control_effect_random_states[&key]), + next_random + ); + assert_eq!( + app.environment.as_ref().unwrap().time_seconds, + first_environment_time + 0.5 + ); + assert_eq!(app.frames_presented, 1); + } + + #[test] + fn replay_environment_delta_uses_last_applied_clock_and_keeps_duplicates_at_zero() { + let initial = control_clock_ms_for_frame(500, 0, 101); + assert_eq!(initial, 0); + assert_eq!(replay_environment_delta_seconds(0, initial), 0.0); + + let first = control_clock_ms_for_frame(500, 1, 101); + let repeated = control_clock_ms_for_frame(500, 1, 101); + assert_eq!(replay_environment_delta_seconds(initial, first), 0.005); + assert_eq!(replay_environment_delta_seconds(first, repeated), 0.0); + let next = control_clock_ms_for_frame(500, 2, 101); + assert_eq!(replay_environment_delta_seconds(first, next), 0.005); + assert_eq!( + replay_environment_delta_seconds(0, control_clock_ms_for_frame(500, 0, 1)), + 0.5 + ); + assert_eq!(replay_environment_delta_seconds(next, 0), 0.0); + } + + #[test] + fn ambient_effect_screen_bounds_follow_vulkan_viewport_y_direction() { + let vertices = [ + [-0.5, 0.25, 0.5], + [-0.5, 0.75, 0.5], + [0.5, 0.75, 0.5], + [0.5, 0.25, 0.5], + ]; + let bounds = ambient_effect_projected_bounds( + &vertices, + [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ], + [100, 100], + ) + .expect("visible clip-space quad"); + assert_eq!(bounds, [25.0, 62.5, 75.0, 87.5]); + } + + #[test] + fn native_tl_sprite_pixels_map_to_current_vulkan_viewport_at_z_999() { + let uv = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]; + let small = screen_tl_quad_vertices_pixels( + [160.0, 120.0], + 40.0, + 30.0, + [640.0, 480.0], + [1.0; 3], + 1.0, + uv, + ); + let resized = screen_tl_quad_vertices_pixels( + [320.0, 240.0], + 80.0, + 60.0, + [1280.0, 960.0], + [1.0; 3], + 1.0, + uv, + ); + + for (small, resized) in small.iter().zip(resized) { + for axis in 0..2 { + assert!((small.position[axis] - resized.position[axis]).abs() < 1.0e-6); + } + assert_eq!(small.position[2], 0.999); + assert_eq!(resized.position[2], 0.999); + } + for (actual, expected) in [ + (small[0].position, [-0.625, -0.375, 0.999]), + (small[2].position, [-0.625, -0.625, 0.999]), + ] { + for axis in 0..3 { + assert!((actual[axis] - expected[axis]).abs() < 1.0e-6); + } + } + assert_eq!(small[0].uv, [0.0, 1.0]); + assert_eq!(small[2].uv, [0.0, 0.0]); + } + + #[test] + fn parses_control_clock_capture() { let parsed = Args::parse(&strings(&[ "--root", "testdata/IS", @@ -8488,8 +14176,18 @@ mod tests { "--control-clock-ms", "500", ])) - .expect("valid bounded replay arguments"); + .expect("controlled clock capture arguments"); assert_eq!(parsed.control_clock_ms, Some(500)); + assert!(Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--control-clock-ms", + "500", + ])) + .expect_err("replay needs multiple frames") + .contains("at least 2")); } #[test] @@ -8504,9 +14202,12 @@ mod tests { }; let mut bindings = vec![MaterialAnimationBinding { initial_phase_index: 0, + normalized_phase_override: None, + ambient_fx_material_alpha: false, variants: vec![MaterialVariantSet { phase_material_indices: vec![10, 11], range_indices: Vec::new(), + pipeline_category: 0, }], base: MaterialPhaseBinding { document: empty_mat0, @@ -8527,6 +14228,7 @@ mod tests { bindings[binding_index].variants.push(MaterialVariantSet { phase_material_indices: vec![20, 21], range_indices: vec![7], + pipeline_category: 0, }); let lightmap_variant = &bindings[binding_index].variants[1]; @@ -8818,6 +14520,322 @@ mod tests { assert_eq!(sky_normal([-128, 127, -64]), [-1.0, 127.0 / 128.0, -0.5]); } + fn set_test_terrain_lod_slots( + terrain: &mut fparkan_terrain_format::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_slot, lod1_slot]) in node_slots.iter().copied().enumerate() { + let offset = node_index * NODE_STRIDE + 8; + nodes[offset..offset + 2].copy_from_slice(&lod0_slot.to_le_bytes()); + nodes[offset + 2..offset + 4].copy_from_slice(&lod1_slot.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()); + } + terrain.nodes_raw = nodes; + terrain.slots.slots_raw = slots; + } + + fn mission_root_for_basement(position: [f32; 3], raw_after_name: u32) -> MissionObjectDraft { + MissionObjectDraft { + original_id: None, + resource_name_raw: b"BUILDS/example.dat".to_vec(), + raw_after_resource: 0, + identity_or_clan_raw: 0, + raw_after_name, + unit_dat_header_flags: Some(0x8000_0000), + position, + orientation_raw: [0.0; 3], + scale: [1.0; 3], + visual_ids: Vec::new(), + properties: Vec::new(), + unit_components: Vec::new(), + } + } + + #[test] + fn basement_delta_updates_one_common_root_for_mesh_and_attached_component() { + let roots = [ + mission_root_for_basement([1.0, 2.0, 100.0], 0), + mission_root_for_basement([3.0, 4.0, 200.0], 1), + ]; + let footprints = [ + MissionBasementFootprint { + object_index: 0, + outer: Vec::new(), + inner: Vec::new(), + flatten_inner_to_mean_outer_z: true, + }, + MissionBasementFootprint { + object_index: 1, + outer: Vec::new(), + inner: Vec::new(), + flatten_inner_to_mean_outer_z: false, + }, + ]; + let placements = [ + BasementPlacement { + placement_delta: 12.5, + }, + BasementPlacement { + placement_delta: 25.0, + }, + ]; + let adjusted = apply_basement_placement_deltas(&roots, &footprints, &placements) + .expect("aligned native placements"); + assert_eq!(adjusted[0].position, [1.0, 2.0, 87.5]); + assert_eq!(adjusted[1].position, roots[1].position); + + let transform = LegacyIron3dEulerTransform { + translation: adjusted[0].position, + orientation_radians: adjusted[0].orientation_raw, + }; + assert_eq!( + transform.try_transform_scaled_point([0.0, 0.0, 0.0], adjusted[0].scale), + Some([1.0, 2.0, 87.5]) + ); + assert_eq!( + transform.try_transform_scaled_point([0.0, 0.0, 4.0], adjusted[0].scale), + Some([1.0, 2.0, 91.5]), + "the attached component inherits the root shift once" + ); + } + + #[test] + fn basement_projection_shadows_and_queries_share_generated_base_only_faces() { + let mut land = fparkan_terrain_format::LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: fparkan_terrain_format::TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![[0.0, 0.0, 5.0], [20.0, 0.0, 5.0], [0.0, 20.0, 5.0]], + normals: vec![[127, 0, 0, 0], [0, 127, 0, 0], [0, 0, 127, 0]], + uv0: vec![[256, 128], [1024, 256], [512, 1536]], + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![fparkan_terrain_format::TerrainFace28 { + flags: FullSurfaceMask(0x400), + material_tag: 0xff00, + aux_tag: 0, + vertices: [0, 1, 2], + neighbors: [None, None, None], + tail_raw: [0; 8], + raw: [0; 28], + }], + }; + set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]); + let source_mesh = project_land_msh_to_static_mesh_in_world_space(&land) + .expect("project one LOD0 terrain face"); + let source_world = TerrainWorld::from_land_msh(&land).expect("source terrain"); + let (empty_mesh, empty_shadows, empty_query) = + prepare_static_terrain_geometry(&land, source_mesh.clone(), &source_world, None) + .expect("keep unmodified terrain when there are no foundations"); + assert_eq!(empty_mesh, source_mesh); + assert_eq!(empty_query.surface_count(), source_world.surface_count()); + assert_eq!(empty_shadows.len(), 1); + assert!(empty_mesh + .vertices + .iter() + .any(|vertex| vertex.normal != [0.0; 3])); + assert!(empty_mesh + .vertices + .iter() + .any(|vertex| vertex.uv != [0.0; 2])); + let band_positions = [ + [-18.25, -18.25, 5.0], + [-2.25, -18.25, 5.0], + [-18.25, -2.25, 5.0], + ]; + let mut foundation = BasementFoundation { + fragments: vec![ + BasementFoundationFragment { + source_face: Some(0), + surface_mask: FullSurfaceMask(0x400), + positions: [[0.0, 0.0, 5.0], [20.0, 0.0, 5.0], [0.0, 20.0, 5.0]], + }, + BasementFoundationFragment { + source_face: None, + surface_mask: FullSurfaceMask(0x8300), + positions: band_positions, + }, + ], + placements: Vec::new(), + }; + let (render_mesh, shadow_receivers) = + project_basement_foundation(&land, &source_mesh, &mut foundation) + .expect("project canonical basement faces"); + let expected = foundation.fragments.clone(); + assert_eq!(render_mesh.draw_ranges.len(), 2); + assert!(render_mesh + .draw_ranges + .iter() + .all(|range| range.material_index == 0xff00)); + assert!(!render_mesh.draw_ranges[0].terrain_base_only); + assert!(render_mesh.draw_ranges[1].terrain_base_only); + assert_eq!(render_mesh.draw_ranges[1].first_index, 3); + assert_eq!(shadow_receivers.len(), expected.len()); + + for (triangle_index, fragment) in expected.iter().enumerate() { + let indices: [u32; 3] = render_mesh.indices[triangle_index * 3..triangle_index * 3 + 3] + .try_into() + .expect("projected foundation triangle"); + let positions = indices.map(|index| render_mesh.vertices[index as usize].position); + assert_eq!(positions, fragment.positions); + assert_eq!( + shadow_receivers[triangle_index].positions, + fragment.positions + ); + assert_eq!( + shadow_receivers[triangle_index].mask, + fragment.surface_mask.0 + ); + } + assert_eq!( + basement_band_uv_vertices(band_positions) + .expect("native per-face UVs") + .map(|vertex| vertex.uv0), + [[815, 815], [1896, 815], [815, 1896]], + "negative coordinates use one native floor origin per triangle" + ); + assert!(render_mesh.vertices[3..6] + .iter() + .flat_map(|vertex| vertex.uv) + .all(|value| (value * 1024.0).fract() == 0.0)); + + let query_world = source_world + .with_basement_foundation(foundation) + .expect("install canonical geometry"); + assert_eq!(query_world.surface_count(), expected.len()); + assert_eq!( + query_world + .basement_foundation() + .expect("installed foundation") + .fragments, + expected + ); + assert_eq!(query_world.height_at([-14.0, -14.0]).unwrap(), Some(5.0)); + } + + #[test] + fn basement_band_normals_sum_packed_faces_for_native_welded_vertices() { + let mut foundation = BasementFoundation { + fragments: vec![ + BasementFoundationFragment { + source_face: None, + surface_mask: FullSurfaceMask(0x8300), + positions: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + }, + BasementFoundationFragment { + source_face: None, + surface_mask: FullSurfaceMask(0x8300), + positions: [[0.005, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 1.0]], + }, + ], + placements: Vec::new(), + }; + let (normals, uv0_words) = canonicalize_basement_band_vertices(&mut foundation) + .expect("native packed normal pass"); + let first = normals[0].expect("first synthetic face"); + let second = normals[1].expect("second synthetic face"); + assert_eq!(first[0], second[0], "sub-centimeter XY vertices are welded"); + assert_eq!(foundation.fragments[1].positions[0], [0.0, 0.0, 0.0]); + assert_eq!( + first[2], second[1], + "shared vertices use both packed face normals" + ); + assert_eq!(uv0_words[1].expect("second face UV words")[0], [0, 0]); + assert_eq!(first[0][1], 0.0); + assert_ne!(first[0], [0.0, 0.0, 127.0 / 128.0]); + } + + #[test] + fn basement_face_normal_distinguishes_degenerate_from_non_finite() { + assert_eq!( + packed_basement_face_normal([[0.0; 3], [1.0; 3], [2.0; 3]]) + .expect("zero normal is a native zero contribution"), + None + ); + assert!(packed_basement_face_normal([ + [f32::NAN, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0] + ]) + .is_err()); + } + + #[test] + fn c00_m02_world_terrain_edge_rounding_keeps_overlay_alpha_in_range() { + // DATA/MAPS/Tut_2/Land.msh face 2525: this split point lands about + // 1.35e-6 outside one source edge after the contour kernel's f32 + // world-coordinate output is converted back to f64 barycentrics. + let source = [ + VulkanStaticVertex { + position: [815.20465, 281.67642, 320.0], + color: [1.0; 3], + normal: [0.0, 0.0, 1.0], + uv: [0.0; 2], + detail_uv: [0.0; 2], + overlay_alpha: 1.0, + }, + VulkanStaticVertex { + position: [775.4386, 308.18713, 360.00003], + color: [1.0; 3], + normal: [0.0, 0.0, 1.0], + uv: [0.0; 2], + detail_uv: [0.0; 2], + overlay_alpha: 1.0, + }, + VulkanStaticVertex { + position: [775.4386, 288.3041, 361.6667], + color: [1.0; 3], + normal: [0.0, 0.0, 1.0], + uv: [0.0; 2], + detail_uv: [0.0; 2], + overlay_alpha: 1.0, + }, + ]; + let split_position = [800.6648, 291.3697, 334.62543]; + let point = [f64::from(split_position[0]), f64::from(split_position[1])]; + let a = [ + f64::from(source[0].position[0]), + f64::from(source[0].position[1]), + ]; + let b = [ + f64::from(source[1].position[0]), + f64::from(source[1].position[1]), + ]; + let c = [ + f64::from(source[2].position[0]), + f64::from(source[2].position[1]), + ]; + let denominator = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + let second_weight = + ((point[0] - a[0]) * (c[1] - a[1]) - (point[1] - a[1]) * (c[0] - a[0])) / denominator; + let third_weight = + ((b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (point[0] - a[0])) / denominator; + let raw_overlay_alpha = [ + 1.0 - second_weight - third_weight, + second_weight, + third_weight, + ] + .map(|weight| weight as f32) + .into_iter() + .sum::<f32>(); + assert!(raw_overlay_alpha > 1.0 && raw_overlay_alpha <= 1.0 + 1.0e-5); + let vertex = interpolate_static_terrain_vertex(split_position, source) + .expect("bounded alpha rounding at a contour split"); + assert_eq!(vertex.overlay_alpha, 1.0); + assert!((0.0..=1.0).contains(&vertex.overlay_alpha)); + } + #[test] fn sun_visibility_uses_a_valid_ray_and_fails_open_without_surfaces() { let camera = FreeFlightCamera { @@ -8859,14 +14877,9 @@ mod tests { raw: [0; 28], }], }; - set_test_terrain_lod0_range(&mut land); + set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]); let shadow_receivers = terrain_shadow_receivers(&land).expect("raw-height receivers"); assert_eq!(shadow_receivers[0].positions[0], [0.0, 0.0, 32.0]); - let mut invalid_shadow_face = land.clone(); - invalid_shadow_face.faces[0].vertices[2] = u16::MAX; - assert!(terrain_shadow_receivers(&invalid_shadow_face) - .expect_err("bad terrain indices must fail instead of inventing vertices") - .contains("unknown vertex 65535")); let terrain = TerrainWorld::from_land_msh(&land).expect("synthetic terrain"); assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(32.0)); let floor_camera = @@ -8942,7 +14955,7 @@ mod tests { raw: [0; 28], }], }; - set_test_terrain_lod0_range(&mut excluded_land); + set_test_terrain_lod_slots(&mut excluded_land, &[[0, u16::MAX]], &[[0, 1]]); let excluded_terrain = TerrainWorld::from_land_msh(&excluded_land).expect("excluded terrain"); assert!(native_sun_unoccluded_with_world( @@ -8989,7 +15002,7 @@ mod tests { raw: [0; 28], }], }; - set_test_terrain_lod0_range(&mut land); + set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]); let terrain = TerrainWorld::from_land_msh(&land).expect("sloped terrain"); assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(33.5)); assert_eq!(terrain.height_at([0.75, 0.25]).unwrap(), Some(34.5)); @@ -9021,15 +15034,27 @@ mod tests { assert_eq!(camera.position, [0.75, 0.25, 36.5]); let mut outside_camera = FreeFlightCamera { - position: [2.0, 2.0, -17.0], + position: [2.0, -2.0, -17.0], ground_clearance: 2.0, ..camera }; - let unchanged = outside_camera.position; outside_camera .keep_above_terrain(&terrain) - .expect("missing terrain sample leaves the camera alone"); - assert_eq!(outside_camera.position, unchanged); + .expect("clamp to the map before sampling the floor"); + assert_eq!(outside_camera.position, [1.0, 0.0, 36.0]); + + outside_camera.position = [-2.0, 2.0, 100.0]; + outside_camera + .keep_above_terrain(&terrain) + .expect("clamp the other map edges without limiting altitude"); + assert_eq!(outside_camera.position, [0.0, 1.0, 100.0]); + + outside_camera.move_speed = 40.0; + outside_camera.advance(&HashSet::from([KeyCode::KeyW, KeyCode::ShiftLeft]), 0.1); + outside_camera + .keep_above_terrain(&terrain) + .expect("accelerated movement still stops at the map edge"); + assert_eq!(outside_camera.position, [1.0, 1.0, 100.0]); let mut unsupported_camera = FreeFlightCamera { position: [0.25, 0.25, 35.5], @@ -9043,6 +15068,49 @@ mod tests { } #[test] + #[ignore = "requires the original GOG installation"] + fn free_flight_camera_stops_at_original_map_edges_under_accelerated_movement() { + let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation"); + let mut engine = + create(EngineServices::new(Arc::new(DirectoryVfs::new(root)))).expect("engine"); + load_mission_static_preview( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + ) + .expect("original mission"); + let terrain = loaded_terrain(&engine).expect("original terrain"); + let [min, max] = terrain.surface_bounds().expect("original map bounds"); + let keys = HashSet::from([KeyCode::KeyW, KeyCode::ShiftLeft]); + for (axis, yaw, edge) in [ + (0, 0.0, max[0]), + (0, std::f32::consts::PI, min[0]), + (1, std::f32::consts::FRAC_PI_2, max[1]), + (1, -std::f32::consts::FRAC_PI_2, min[1]), + ] { + let mut camera = FreeFlightCamera::from_bounds(min, max).expect("camera"); + camera.position = [ + (min[0] + max[0]) * 0.5, + (min[1] + max[1]) * 0.5, + max[2] + 10.0, + ]; + camera.yaw = yaw; + camera.pitch = 0.0; + for _ in 0..512 { + camera.advance(&keys, 0.1); + camera.keep_above_terrain(terrain).expect("map constraint"); + for axis in 0..2 { + assert!((min[axis]..=max[axis]).contains(&camera.position[axis])); + } + } + assert_eq!(camera.position[axis], edge); + } + } + + #[test] fn world_sort_key_uses_the_first_indexed_vertex() { let mesh = VulkanStaticMesh::smoke_triangle(); let distance = first_indexed_vertex_distance(&mesh, 0, [0.0, -0.55, -3.0]) @@ -9149,6 +15217,71 @@ mod tests { } #[test] + fn controlled_readback_sequence_requires_and_parses_replay_clock() { + let parsed = Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--frames", + "11", + "--control-clock-ms", + "0", + "--readback-sequence-out", + "target/frames", + ])) + .expect("valid fixed-clock readback sequence"); + assert_eq!( + parsed.readback_sequence_out, + Some(PathBuf::from("target/frames")) + ); + assert_eq!(parsed.control_clock_ms, Some(0)); + + let error = Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--frames", + "11", + "--readback-sequence-out", + "target/frames", + ])) + .expect_err("sequence captures need deterministic clock input"); + assert!(error.contains("requires --control-clock-ms")); + } + + #[test] + fn readback_sequence_writes_clock_named_frame_without_overwrite() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after epoch") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "fparkan-readback-sequence-{}-{nonce}", + std::process::id() + )); + let artifact = VulkanReadbackArtifact { + format: 37, + width: 2, + height: 1, + bytes: vec![1, 2, 3, 4, 5, 6, 7, 8], + }; + let path = write_readback_sequence_frame(&directory, 3, 50, &artifact) + .expect("write frame artifact"); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("frame-000003-clock-0000000050ms-2x1.rgba") + ); + assert_eq!( + std::fs::read(&path).expect("read frame artifact"), + artifact.bytes + ); + assert!(write_readback_sequence_frame(&directory, 3, 50, &artifact).is_err()); + std::fs::remove_dir_all(directory).expect("remove frame artifact directory"); + } + + #[test] fn parses_static_animation_frame() { let parsed = Args::parse(&strings(&[ "--root", @@ -9324,6 +15457,59 @@ mod tests { } #[test] + fn parented_camera_preview_uses_effective_world_matrix_and_legacy_uses_local() { + let local_words = [ + 1065353216, 0, 0, 1065353216, 0, 1065353216, 0, 1073741824, 0, 0, 1065353216, + 1077936128, 0, 0, 0, 1065353216, + ]; + let world_words = [ + 1065353216, 0, 0, 1092616192, 0, 1065353216, 0, 1101004800, 0, 0, 1065353216, + 1106247680, 0, 0, 0, 1065353216, + ]; + let make_json = |extra: &str| { + format!( + "{{\"schema\":\"fparkan-legacy-camera-v1\",\"render_input_usable\":true,\"selector0_words\":{:?},{}\"viewport\":[0,0,1280,1024],\"near_plane\":0.5,\"far_plane\":700.0,\"field_of_view_radians\":1.04}}", + local_words, extra + ) + }; + let legacy = parse_legacy_camera_capture(make_json("").as_bytes()) + .expect("legacy camera without parent metadata"); + assert_eq!(legacy.preview_matrix_words().unwrap(), local_words); + assert_eq!( + legacy.preview_camera_frame().unwrap().position, + [1.0, 2.0, 3.0] + ); + + let parented = parse_legacy_camera_capture( + make_json(&format!( + "\"parented_camera\":true,\"effective_world_matrix_space\":\"world\",\"effective_world_matrix_words\":{:?},", + world_words + )) + .as_bytes(), + ) + .expect("parented camera with effective world matrix"); + assert_eq!(parented.preview_matrix_words().unwrap(), world_words); + let preview = parented.preview_camera_frame().unwrap(); + assert_eq!(preview.position, [10.0, 20.0, 30.0]); + assert_ne!( + preview.position, + legacy.preview_camera_frame().unwrap().position + ); + + let missing_world = make_json("\"parented_camera\":true,"); + assert!(parse_legacy_camera_capture(missing_world.as_bytes()) + .expect_err("parented local-only input must be rejected") + .contains("parented legacy camera requires an effective world-space matrix")); + let missing_marker = make_json(&format!( + "\"parented_camera\":true,\"effective_world_matrix_words\":{:?},", + world_words + )); + assert!(parse_legacy_camera_capture(missing_marker.as_bytes()) + .expect_err("world matrix requires space marker") + .contains("effective_world_matrix_space and effective_world_matrix_words")); + } + + #[test] fn parented_camera_preview_uses_effective_world_matrix_fixture() { let fixture = include_bytes!("../testdata/legacy-camera-parented.json"); let capture = parse_legacy_camera_capture(fixture).expect("valid parented camera fixture"); @@ -9649,303 +15835,1064 @@ mod tests { } #[test] - fn native_tl_sprite_pixels_use_current_viewport_and_preserve_depth_999() { - let uv = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]; - let small = screen_tl_quad_vertices_pixels( - [160.0, 120.0], - 40.0, - 30.0, - [640.0, 480.0], - [1.0; 3], - 1.0, - uv, + fn control_type4_basis_branches_preserve_anisotropic_owner_scale() { + let owner_scale = [2.0, 3.0, 4.0]; + let equal = [ + control_point([0.0; 3], [1.0, 1.0, 0.0]), + control_point([0.0; 3], [1.0, 1.0, 0.0]), + control_point([0.0; 3], [1.0, 1.0, 0.0]), + ]; + let equal_matrix = + control_type4_local_matrix([&equal[0], &equal[1], &equal[2]], owner_scale).unwrap(); + assert_matrix_columns( + equal_matrix, + [ + [2.0, 3.0, 0.0], + [-3.0, 2.0, 0.0], + [0.0, 0.0, 13.0_f32.sqrt()], + ], ); - let resized = screen_tl_quad_vertices_pixels( - [320.0, 240.0], - 80.0, - 60.0, - [1280.0, 960.0], - [1.0; 3], - 1.0, - uv, + + let unique = [ + control_point([1.0; 3], [1.0, 2.0, 3.0]), + control_point([2.0; 3], [4.0, 5.0, 6.0]), + control_point([3.0; 3], [-1.0, 1.0, 2.0]), + ]; + let unique_matrix = + control_type4_local_matrix([&unique[0], &unique[1], &unique[2]], owner_scale).unwrap(); + assert_matrix_columns( + unique_matrix, + [[2.0, 6.0, 12.0], [8.0, 15.0, 24.0], [-2.0, 3.0, 8.0]], ); - let wide = screen_tl_quad_vertices_pixels( - [640.0, 360.0], - 100.0, - 45.0, - [1280.0, 720.0], - [1.0; 3], - 1.0, - uv, + assert_eq!( + [ + unique_matrix[0][3], + unique_matrix[1][3], + unique_matrix[2][3] + ], + [4.0, 6.0, 8.0] ); - for (small, resized) in small.iter().zip(resized) { - for axis in 0..2 { - assert!((small.position[axis] - resized.position[axis]).abs() < 1.0e-6); - } - assert_eq!(small.position[2], 0.999); - assert_eq!(resized.position[2], 0.999); - } - assert_eq!(small[0].uv, [0.0, 1.0]); - assert_eq!(small[2].uv, [0.0, 0.0]); - for (actual, expected) in [ - (wide[0].position, [-0.15625, 0.125, 0.999]), - (wide[2].position, [-0.15625, -0.125, 0.999]), + for (directions, expected) in [ + ( + [[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 2.0]], + ), + ( + [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + [[0.0, 0.0, 4.0], [0.0, -1.0, 0.0], [4.0, 0.0, 0.0]], + ), + ( + [[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], + [[0.0, 3.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 3.0]], + ), + ( + [[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 2.0]], + ), ] { - for axis in 0..3 { - assert!((actual[axis] - expected[axis]).abs() < 1.0e-6); - } + let repeated = directions.map(|direction| control_point([0.0; 3], direction)); + let matrix = + control_type4_local_matrix([&repeated[0], &repeated[1], &repeated[2]], owner_scale) + .unwrap(); + assert_matrix_columns(matrix, expected); } } #[test] - fn native_tl_depth_999_is_nearer_than_opaque_geometry_at_320_units() { - let camera = FreeFlightCamera { - position: [0.0; 3], - yaw: 0.0, - pitch: 0.0, - horizontal_fov: 1.3, - near_plane: 0.5, - far_plane: 700.0, - move_speed: 1.0, - ground_clearance: 0.0, - }; - let frame = camera.preview_frame(16.0 / 9.0); - let opaque_clip = row_vector_transform([320.0, 0.0, 0.0, 1.0], frame.clip_from_world); - let opaque_depth = opaque_clip[2] / opaque_clip[3]; + fn control_type4_world_matrix_rotates_basis_and_keeps_scale_off_translation() { + let point = control_point([1.0, 0.0, 0.0], [1.0, 0.0, 0.0]); + let local = + control_type4_effect_local_matrix([&point, &point, &point], [2.0, 3.0, 4.0]).unwrap(); + let matrix = control_type4_world_from_effect( + local, + [2.0, 3.0, 4.0], + LegacyIron3dEulerTransform { + translation: [10.0, 20.0, 30.0], + orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2], + }, + None, + VulkanNodePose { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + ) + .unwrap(); - assert!((opaque_depth - 0.999151_18).abs() < 1.0e-6); - assert!(0.999 < opaque_depth); + assert_matrix_columns( + matrix, + [[0.0, 4.0, 0.0], [-9.0, 0.0, 0.0], [0.0, 0.0, 16.0]], + ); + for (actual, expected) in [matrix[0][3], matrix[1][3], matrix[2][3]] + .into_iter() + .zip([10.0, 22.0, 30.0]) + { + assert!((actual - expected).abs() < 1.0e-5); + } } - fn facing_mesh( - batch_flags: u16, - positions: &[[f32; 3]], - ) -> (VulkanStaticMesh, VulkanStaticDrawRange) { - let range = VulkanStaticDrawRange { - first_index: 0, - index_count: u32::try_from(positions.len()).expect("fixture index count fits u32"), - material_index: 0, - lightmap_index: 0, - batch_flags, - pipeline_state: LegacyPipelineState::default(), - terrain_base_only: false, - alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, + #[test] + fn mounted_control_effect_uses_component_pose_before_owner_world_transform() { + let point = control_point([1.0, 0.0, 0.0], [1.0, 0.0, 0.0]); + let mount_pose = VulkanNodePose { + translation: [1.0, 2.0, 3.0], + rotation: [ + 0.0, + 0.0, + std::f32::consts::FRAC_1_SQRT_2, + std::f32::consts::FRAC_1_SQRT_2, + ], }; - let mesh = VulkanStaticMesh { - vertices: positions - .iter() - .copied() - .map(|position| VulkanStaticVertex { - position, - color: [1.0; 3], - normal: [0.0, 0.0, 1.0], - uv: [0.0; 2], - detail_uv: [0.0; 2], - overlay_alpha: 0.0, - }) - .collect(), - indices: (0..positions.len()) - .map(|index| u32::try_from(index).expect("fixture index fits u32")) - .collect(), - draw_ranges: vec![range], + let owner_node_pose = VulkanNodePose { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, 1.0], }; - (mesh, range) - } + let local = control_type4_effect_local_matrix([&point, &point, &point], [1.0; 3]).unwrap(); + let matrix = control_type4_world_from_effect( + local, + [2.0, 3.0, 4.0], + LegacyIron3dEulerTransform { + translation: [10.0, 20.0, 30.0], + orientation_radians: [0.0; 3], + }, + Some(mount_pose), + owner_node_pose, + ) + .unwrap(); - fn native_front_triangle() -> [[f32; 3]; 3] { - [[-0.2, -0.2, -1.0], [0.2, -0.2, -1.0], [0.0, 0.2, -1.0]] + for (actual, expected) in [matrix[0][3], matrix[1][3], matrix[2][3]] + .into_iter() + .zip([12.0, 29.0, 42.0]) + { + assert!((actual - expected).abs() < 1.0e-5); + } } - fn camera_face_triangle(camera: PreviewCameraFrame, mirror: bool) -> [[f32; 3]; 3] { - let center = add3(camera.position, scale3(camera.forward, 10.0)); - let mirror = if mirror { -1.0 } else { 1.0 }; - [ - add3( - add3(center, scale3(camera.right, -2.0 * mirror)), - scale3(camera.up, -2.0), + #[test] + fn plant_node23_control_points_match_captured_native_world_positions() { + let root_transform = LegacyIron3dEulerTransform { + translation: [323.55048, 806.97266, 0.36829287], + orientation_radians: [0.0, 0.0, 1.6262363], + }; + let owner_node_pose = VulkanNodePose { + translation: [0.0, -68.15999, 21.12], + rotation: [0.0, 0.0, 0.0, 1.0], + }; + let points = [ + ( + [-12.964036, 12.991658, -21.119995], + [379.35242, 797.0855, 0.3682986], ), - add3( - add3(center, scale3(camera.right, 2.0 * mirror)), - scale3(camera.up, -2.0), + ( + [13.49774, 12.991658, -21.119995], + [377.8861, 823.5066, 0.3682986], ), - add3(center, scale3(camera.up, 2.0)), - ] - } + ( + [9.507235, -3.006884, -21.119993], + [394.08118, 820.40875, 0.3683005], + ), + ( + [-9.080112, -3.2843084, -21.119991], + [395.38815, 801.86536, 0.3683024], + ), + ]; - fn projected_vulkan_area(camera: PreviewCameraFrame, triangle: [[f32; 3]; 3]) -> f32 { - let screen = triangle.map(|point| { - let clip = - row_vector_transform([point[0], point[1], point[2], 1.0], camera.clip_from_world); - assert!(clip[3] > 0.0 && clip.iter().all(|value| value.is_finite())); - // This is the positive-height viewport transform used by the Vulkan renderer. - [ - (clip[0] / clip[3] + 1.0) * 0.5, - (clip[1] / clip[3] + 1.0) * 0.5, - ] - }); - let shoelace = (0..3) - .map(|index| { - let next = (index + 1) % 3; - screen[index][0] * screen[next][1] - screen[next][0] * screen[index][1] - }) - .sum::<f32>() - * 0.5; - // Vulkan's framebuffer signed-area definition is the negative shoelace area. - -shoelace + for (local_position, expected_world) in points { + let point = control_point(local_position, [1.0, 0.0, 0.0]); + let local = control_type4_effect_local_matrix([&point, &point, &point], [1.0; 3]) + .expect("CPT descriptor local matrix"); + let world = control_type4_world_from_effect( + local, + [1.0; 3], + root_transform, + None, + owner_node_pose, + ) + .expect("attached Control owner matrix"); + for axis in 0..3 { + assert!( + (world[axis][3] - expected_world[axis]).abs() < 0.1, + "axis {axis}: actual {} expected {}", + world[axis][3], + expected_world[axis] + ); + } + } } #[test] - fn native_msh_cull_mode_matches_legacy_and_free_flight_camera_projection() { - let identity = RawCameraTransform { - words: [ - 1.0_f32.to_bits(), - 0.0_f32.to_bits(), - 0.0_f32.to_bits(), - 0.0_f32.to_bits(), - 0.0_f32.to_bits(), - 1.0_f32.to_bits(), - 0.0_f32.to_bits(), - 0.0_f32.to_bits(), - 0.0_f32.to_bits(), - 0.0_f32.to_bits(), - 1.0_f32.to_bits(), - 0.0_f32.to_bits(), - 0.0_f32.to_bits(), - 0.0_f32.to_bits(), - 0.0_f32.to_bits(), - 1.0_f32.to_bits(), + fn ambient_effect_world_refreshes_from_each_component_pose_buffer() { + let mut poses = vec![fparkan_animation::Pose::default(); 24]; + poses[23].translation = [0.0, -68.15999, 21.12]; + let mut slot = AmbientEffectGpuSlot { + kind: AmbientEffectKind::Op3Type2, + time_mode: 1, + header_flags: 0, + random_instance_key: ControlEffectInstanceKey { + object_index: 0, + component_index: 0, + table_index: 8, + row_index: 6, + }, + random_seed: 1, + phase_jitter: 0.0, + random_shift: [0.0; 3], + owner_object_index: 0, + owner_component_index: 0, + owner_node_index: 23, + owner_component_is_mounted: false, + root_transform: LegacyIron3dEulerTransform { + translation: [323.55048, 806.97266, 0.36829287], + orientation_radians: [0.0, 0.0, 1.6262363], + }, + owner_scale: [1.0; 3], + mount_pose: None, + local_from_effect: [ + [1.0, 0.0, 0.0, -12.964036], + [0.0, 1.0, 0.0, 12.991658], + [0.0, 0.0, 1.0, -21.119995], + [0.0, 0.0, 0.0, 1.0], ], + control_source: String::new(), + control_table_index: 8, + control_row_index: 6, + control_point_indices: [104; 3], + control_point_owners: [23; 3], + command: endpoint_op3_command(), + command_index: 0, + duration_seconds: 1.0, + world_from_effect: [[0.0; 4]; 4], + range: EnvironmentGpuRange { + range_index: 0, + vertex_start: 0, + vertex_capacity: 4, + material_index: 0, + }, + additional_particle_ranges: Vec::new(), + material_animation_index: 0, + additional_particle_material_animation_indices: Vec::new(), + smoke_emitter_state: None, + op7_emitter_state: None, }; - let projection = LegacyD3d7Projection { - viewport: [0, 0, 1280, 1024], - near_plane: 0.5, - far_plane: 700.0, - field_of_view_radians: 1.3, - }; - let legacy = PreviewCameraFrame::from_legacy_d3d7(identity, projection) - .expect("valid legacy camera projection"); - let free_flight = FreeFlightCamera { - position: legacy.position, - yaw: 0.0, - pitch: 0.0, - horizontal_fov: 1.3, - near_plane: 0.5, - far_plane: 700.0, - move_speed: 1.0, - ground_clearance: 0.0, + let mut component_pose_buffers = vec![vec![Some(NodePoseBuffer { poses })]]; + let first = ambient_effect_world_from_component_pose(&slot, &component_pose_buffers) + .expect("first sampled owner pose"); + assert!((first[0][3] - 379.35242).abs() < 0.1 && (first[1][3] - 797.0855).abs() < 0.1); + + let pose = &mut component_pose_buffers[0][0].as_mut().unwrap().poses[23]; + pose.translation = [ + pose.translation[0] + 1.0, + pose.translation[1] + 2.0, + pose.translation[2] + 3.0, + ]; + let second = ambient_effect_world_from_component_pose(&slot, &component_pose_buffers) + .expect("next sampled owner pose"); + slot.world_from_effect = second; + assert_ne!(first, slot.world_from_effect); + let expected_delta = rotate_unit_vector( + [1.0, 2.0, 3.0], + [ + 0.0, + 0.0, + (1.6262363_f32 * 0.5).sin(), + (1.6262363_f32 * 0.5).cos(), + ], + ); + for (axis, expected) in expected_delta.into_iter().enumerate() { + let delta = slot.world_from_effect[axis][3] - first[axis][3]; + assert!((delta - expected).abs() < 0.1, "axis {axis}: {delta}"); } - .preview_frame(1280.0 / 1024.0); + } - for camera in [legacy, free_flight] { - let front = camera_face_triangle(camera, false); - let back = [front[0], front[2], front[1]]; - let front_d = dot3( - sub3(camera.position, front[0]), - cross3(sub3(front[1], front[0]), sub3(front[2], front[0])), - ); - let back_d = dot3( - sub3(camera.position, back[0]), - cross3(sub3(back[1], back[0]), sub3(back[2], back[0])), - ); - assert!(front_d > 0.0); - assert!(back_d < 0.0); + #[test] + fn mode1_control_effects_hold_active_with_full_opacity_after_duration() { + let phase = native_command_phase( + 0, + ambient_effect_phase(1, 0, 1.25, 1.0).expect("mode 1 phase"), + 0.0, + ); + assert_eq!(phase, 1.0); + let identity = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; - let front_vk_area = projected_vulkan_area(camera, front); - let back_vk_area = projected_vulkan_area(camera, back); - // Vulkan CLOCKWISE front faces have negative framebuffer area. - // The native-visible d>0 face is therefore a Vulkan back face. - assert!(front_vk_area > 0.0, "front area: {front_vk_area}"); - assert!(back_vk_area < 0.0, "back area: {back_vk_area}"); - assert_eq!(native_msh_cull_mode(0, 1), LegacyCullMode::FrontFace); + let lamp = + evaluate_op3_type2_command(&endpoint_op3_command(), 0, &identity, 1.25, phase, 0) + .expect("lamp endpoint"); + assert!(lamp.active); + assert_eq!(lamp.color[3], 1.0); - let mirrored_front = camera_face_triangle(camera, true); - let mirrored_d = dot3( - sub3(camera.position, mirrored_front[0]), - cross3( - sub3(mirrored_front[1], mirrored_front[0]), - sub3(mirrored_front[2], mirrored_front[0]), - ), + let op9 = endpoint_gener_ball_command(FxOpcode::Op9); + let generated = evaluate_f_gener_ball_mesh( + &op9, + 0, + &identity, + phase, + 1.25, + [0.0, 0.0, 500.0], + AMBIENT_EFFECT_NATIVE_CATEGORY, + ) + .expect("generator sphere endpoint"); + assert!(generated.active); + assert_eq!(generated.color[3], 1.0); + assert_eq!(generated.vertices.len(), 88); + assert_eq!(generated.indices.len(), 120); + assert_eq!(generated.native_category, 0); + + let op4 = endpoint_gener_ball_command(FxOpcode::Op4); + let orb = evaluate_f_gener_ball(&op4, 0, &identity, phase, 1.25, [0.0, 0.0, 500.0]) + .expect("opcode 4 billboard endpoint"); + assert!(orb.active); + assert_eq!(orb.color[3], 1.0); + } + + #[test] + fn op9_mesh_bridge_allocates_one_range_per_quad_and_cap() { + let command = endpoint_gener_ball_command(FxOpcode::Op9); + let identity = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let frame = evaluate_f_gener_ball_mesh( + &command, + 0, + &identity, + 0.5, + 0.5, + [0.0, 0.0, 500.0], + AMBIENT_EFFECT_NATIVE_CATEGORY, + ) + .expect("category-zero Op9 mesh"); + let primitives = ambient_effect_mesh_primitive_layout(&frame) + .expect("native Op9 quad/cap primitive layout"); + assert_eq!(primitives.len(), 24); + assert_eq!( + primitives + .iter() + .filter(|item| item.vertex_count == 4) + .count(), + 16 + ); + assert_eq!( + primitives + .iter() + .filter(|item| item.vertex_count == 3) + .count(), + 8 + ); + + let mut mesh = VulkanStaticMesh { + vertices: Vec::new(), + indices: Vec::new(), + draw_ranges: Vec::new(), + }; + let ranges = primitives + .iter() + .copied() + .map(|primitive| { + let range = append_environment_range( + &mut mesh, + 0, + ambient_effect_mesh_primitive_vertices(&frame, primitive), + ambient_effect_mesh_primitive_indices(&frame, primitive) + .expect("primitive-local indices"), + LegacyPipelineState::default(), + ) + .expect("allocate one native primitive range"); + write_ambient_effect_mesh_primitive(&mut mesh, &range, &frame, primitive) + .expect("write native primitive frame"); + range + }) + .collect::<Vec<_>>(); + + assert_eq!(ranges.len(), 24); + assert_eq!(mesh.draw_ranges.len(), 24); + assert_eq!(mesh.indices.len(), 120); + assert!(mesh.draw_ranges.iter().all(|range| { + range.alpha_test_reference == VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE + })); + assert_eq!( + mesh.draw_ranges + .iter() + .map(|range| range.index_count) + .sum::<u32>(), + 120 + ); + for (range, primitive) in ranges.iter().zip(primitives) { + let vertices = + &mesh.vertices[range.vertex_start..range.vertex_start + range.vertex_capacity]; + assert_eq!(range.vertex_capacity, primitive.vertex_count); + for (offset, vertex) in vertices.iter().enumerate() { + assert_eq!( + vertex.position, + frame.vertices[primitive.vertex_start + offset] + ); + assert_eq!(vertex.uv, frame.uv[primitive.vertex_start + offset]); + assert_eq!(vertex.overlay_alpha, frame.color[3]); + } + let range_indices = &mesh.draw_ranges[range.range_index]; + assert_eq!(range_indices.index_count as usize, primitive.index_count); + let first_index = range_indices.first_index as usize; + assert_eq!( + mesh.indices[first_index], + u32::try_from(range.vertex_start).unwrap() ); - assert!(mirrored_d < 0.0); - assert!(projected_vulkan_area(camera, mirrored_front) < 0.0); - assert_eq!(native_msh_cull_mode(0, -1), LegacyCullMode::BackFace); + let camera = [10.0, -5.0, 2.0]; + let expected = sub3(frame.vertices[primitive.vertex_start], camera); + let expected = dot3(expected, expected).sqrt(); + let actual = first_indexed_vertex_distance(&mesh, range.range_index, camera) + .expect("native Op9 primitive distance key"); + assert!((actual - expected).abs() < 1e-5); + } + + for selector in [1, 2] { + let mut command = command.clone(); + write_effect_u32(Arc::make_mut(&mut command.raw_body), 0xC4, selector); + let frame = evaluate_f_gener_ball_mesh( + &command, + 0, + &identity, + 0.5, + 0.5, + [0.0, 0.0, 500.0], + AMBIENT_EFFECT_NATIVE_CATEGORY, + ) + .expect("Op9 selector frame"); + assert!(!ambient_effect_mesh_primitive_layout(&frame) + .expect("selector native quad/cap layout") + .is_empty()); } } #[test] - fn native_msh_handedness_uses_full_placement_transform_and_handles_singular_scale() { - let rotated = LegacyIron3dEulerTransform { - translation: [8.0, -4.0, 3.0], - orientation_radians: [0.4, -0.7, 1.2], - }; - assert_eq!(native_msh_handedness(rotated, [2.0, 3.0, 4.0]), 1); - assert_eq!(native_msh_handedness(rotated, [-2.0, 3.0, 4.0]), -1); - assert_eq!(native_msh_handedness(rotated, [0.0, 3.0, 4.0]), 0); - assert_eq!(native_msh_cull_mode(0, 0), LegacyCullMode::Disabled); + fn mode2_control_effects_repeat_and_reset_at_header_duration() { + assert_eq!(ambient_effect_phase(2, 0, 1.0, 2.0), Some(0.5)); + assert_eq!(ambient_effect_phase(2, 0, 2.0, 2.0), Some(0.0)); + assert_eq!(ambient_effect_phase(2, 0, 2.25, 2.0), Some(0.125)); + assert_eq!(ambient_effect_phase(1, 0, 2.0, 2.0), Some(1.0)); + assert_eq!(ambient_effect_phase(0, 0, 0.5, 0.0), Some(0.0)); + assert_eq!(ambient_effect_phase(3, 0, 0.5, 2.0), None); + assert_eq!(ambient_effect_phase(2, 0x20, 0.5, 1.0), Some(1.0)); + assert_eq!(ambient_effect_phase(2, 0x20, 0.75, 1.0), Some(0.5)); + assert_eq!(ambient_effect_phase(1, 0x200, 0.5, 1.0), Some(0.25)); + assert_eq!(ambient_effect_phase(1, 0x220, 0.5, 1.0), Some(0.5)); + assert_eq!(ambient_effect_phase(1, 0x220, 1.5, 1.0), Some(-2.5)); + assert_eq!(ambient_effect_phase(1, 0x20, 1.5, 1.0), Some(-1.0)); } #[test] - fn native_msh_4000_samples_only_first_triangle_and_raw_two_sided_bypasses_it() { - let front = native_front_triangle(); - let back = front.map(|point| [-point[0], point[1], point[2]]); - let front_then_back = [front[0], front[1], front[2], back[0], back[1], back[2]]; - let (mesh, range) = facing_mesh(0x4000, &front_then_back); - assert!(native_msh_first_triangle_visible( - &mesh, &range, [0.0; 3], 1 - )); + fn control_lamp_accepts_only_supported_bodies_and_keeps_mat0_state() { + assert_eq!( + ambient_effect_command_kind(&endpoint_op3_command()), + Some(AmbientEffectKind::Op3Type2) + ); + let mut type0 = endpoint_op3_command(); + write_effect_u32(Arc::make_mut(&mut type0.raw_body), 0, 0); + assert_eq!( + ambient_effect_command_kind(&type0), + Some(AmbientEffectKind::Op3Type0) + ); + assert!(ambient_effect_render_selector_allows_header(0x10, 0)); + assert!(!ambient_effect_render_selector_allows_header(0x800, 0)); + assert!(!ambient_effect_render_selector_allows_header(0xD00, 0)); + assert!(ambient_effect_render_selector_allows_header(0xD00, 1)); + assert!(ambient_effect_render_selector_allows_header(0xD00, 2)); + for opcode in [FxOpcode::Op9, FxOpcode::Op4] { + assert_eq!( + ambient_effect_command_kind(&endpoint_gener_ball_command(opcode)), + Some(AmbientEffectKind::GenerBall), + "{opcode:?}" + ); + } + for selector in [1, 2] { + let mut op9 = endpoint_gener_ball_command(FxOpcode::Op9); + write_effect_u32(Arc::make_mut(&mut op9.raw_body), 0xC4, selector); + assert_eq!( + ambient_effect_command_kind(&op9), + Some(AmbientEffectKind::GenerBall), + "Op9 selector {selector}" + ); + } + let mut invalid_op9 = endpoint_gener_ball_command(FxOpcode::Op9); + write_effect_u32(Arc::make_mut(&mut invalid_op9.raw_body), 0xC4, 3); + assert_eq!(ambient_effect_command_kind(&invalid_op9), None); + assert_eq!( + ambient_effect_command_kind(&endpoint_op8_command(6)), + Some(AmbientEffectKind::SmokePool) + ); + for pool_count in [0, 4097] { + assert_eq!( + ambient_effect_command_kind(&endpoint_op8_command(pool_count)), + None + ); + } + for raw_mode in [0, 1] { + let op7 = endpoint_op7_command(raw_mode, 1.5, 0.0, 3, 2); + assert_eq!( + ambient_effect_command_kind(&op7), + Some(AmbientEffectKind::Op7ParticlePool), + "raw mode {raw_mode}" + ); + assert_eq!(ambient_effect_op7_range_capacity(&op7), Some(6)); + } + let accelerated_op7 = endpoint_op7_command(1, 1.5, 0.25, 3, 2); + assert_eq!( + ambient_effect_command_kind(&accelerated_op7), + Some(AmbientEffectKind::Op7ParticlePool) + ); + assert_eq!(ambient_effect_op7_range_capacity(&accelerated_op7), Some(6)); + for unsupported in [ + endpoint_op7_command(2, 1.5, 0.0, 3, 2), + endpoint_op7_command(0, 1.5, f32::NAN, 3, 2), + endpoint_op7_command(0, 0.0, 0.0, 3, 2), + endpoint_op7_command(0, f32::NAN, 0.0, 3, 2), + endpoint_op7_command(0, 1.5, 0.0, u32::MAX, 2), + ] { + assert_eq!(ambient_effect_command_kind(&unsupported), None); + } - let back_then_front = [back[0], back[1], back[2], front[0], front[1], front[2]]; - let (mesh, range) = facing_mesh(0x4000, &back_then_front); - assert!(!native_msh_first_triangle_visible( - &mesh, &range, [0.0; 3], 1 - )); + let state = ambient_effect_pipeline_state(2).expect("native additive MAT0 category"); + assert_eq!(state.blend, LegacyBlendMode::Additive); + assert!(state.alpha_test); + assert_eq!(state.depth, LegacyDepthMode::TestReadOnly); + } - let (mesh, range) = facing_mesh(0x4002, &back_then_front); - assert!(native_msh_first_triangle_visible( - &mesh, &range, [0.0; 3], 1 - )); + #[test] + fn op1_control_lights_use_native_phase_owner_transform_and_flags() { + let mut slot = ControlPointLightSlot { + time_mode: 1, + header_flags: 0, + random_instance_key: ControlEffectInstanceKey { + object_index: 0, + component_index: 0, + table_index: 0, + row_index: 0, + }, + random_seed: 1, + phase_jitter: 0.0, + random_shift: [0.0; 3], + duration_seconds: 1.0, + owner_object_index: 0, + owner_component_index: 0, + owner_node_index: 0, + owner_component_is_mounted: false, + root_transform: LegacyIron3dEulerTransform { + translation: [10.0, 20.0, 30.0], + orientation_radians: [0.0; 3], + }, + owner_scale: [1.0; 3], + mount_pose: None, + local_from_effect: [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 2.0], + [0.0, 0.0, 1.0, 3.0], + [0.0, 0.0, 0.0, 1.0], + ], + world_from_effect: [[0.0; 4]; 4], + command_index: 0, + command: endpoint_op1_command(6, 0.5, 1.0), + }; + let poses = vec![vec![None]]; + let mounts = vec![vec![None]]; + + let inactive = slot.clone(); + let random_samples = HashMap::new(); + assert!(update_control_point_lights( + &mut [inactive], + &poses, + &mounts, + 0.25, + &random_samples, + false, + ) + .is_empty()); + let lights = update_control_point_lights( + std::slice::from_mut(&mut slot), + &poses, + &mounts, + 0.75, + &random_samples, + false, + ); + assert_eq!(lights.len(), 1); + let (light, flags) = lights[0]; + assert_eq!(light.position, [13.0, 22.0, 33.0]); + assert_eq!(light.rgb, [0.25, 0.5, 0.75]); + assert_eq!(light.range, 15.0); + assert_eq!(light.coefficients, [1.0, 0.5, 0.25]); + assert_eq!(flags, 0xA000_0000); assert_eq!( - native_msh_cull_mode(range.batch_flags, 1), - LegacyCullMode::Disabled + [ + slot.world_from_effect[0][3], + slot.world_from_effect[1][3], + slot.world_from_effect[2][3] + ], + [11.0, 22.0, 33.0] + ); + + slot.header_flags = 0x29; + slot.random_seed = 0x1234_5678; + slot.phase_jitter = 0.4; + slot.random_shift = [2.0, 4.0, 6.0]; + let sibling_slots = [slot.clone(), slot.clone()]; + let mut states = HashMap::new(); + let first = advance_control_effect_random_states( + &mut states, + &[], + &sibling_slots, + SceneSampleId::Replay(7), + 150, + 1.5, + ); + assert_eq!(first.len(), 1); + let key = slot.random_instance_key; + let first_sample = first[&key]; + assert_eq!(first_sample.phase_jitter, 0.0, "the -1 phase skips jitter"); + assert_ne!(first_sample.accumulated_shift, [0.0; 3]); + let mut expected_state = ControlEffectRandomState::new( + slot.random_seed, + slot.header_flags, + slot.phase_jitter, + slot.random_shift, + ); + assert_eq!( + first_sample, + expected_state.advance(SceneSampleId::Replay(7), 150, -1.0) + ); + assert_eq!( + advance_control_effect_random_states( + &mut states, + &[], + &sibling_slots, + SceneSampleId::Replay(7), + 150, + 1.5, + )[&key], + first_sample, + "a repeated read of the same captured update reuses the shared sample" + ); + let second = advance_control_effect_random_states( + &mut states, + &[], + &sibling_slots, + SceneSampleId::Replay(8), + 200, + 0.5, + ); + assert_eq!( + second[&key], + expected_state.advance(SceneSampleId::Replay(8), 200, 1.0) + ); + assert_ne!(second[&key].phase_jitter, first_sample.phase_jitter); + assert_ne!( + second[&key].accumulated_shift, + first_sample.accumulated_shift + ); + assert_eq!(states[&key].last_update_clock_ms, Some(200)); + + let negative_non_sentinel = + ambient_effect_phase(1, 0x220, 1.5, 1.0).expect("post-duration transformed phase"); + assert_eq!(negative_non_sentinel, -2.5); + let mut negative_state = ControlEffectRandomState::new( + slot.random_seed, + 0x229, + slot.phase_jitter, + slot.random_shift, + ); + assert_eq!( + native_command_phase(0x229, negative_non_sentinel, 0.4), + 0.0, + "a phase below -1 clamps to zero rather than using the -1 sentinel" + ); + assert_ne!( + negative_state + .advance(SceneSampleId::Live(1), 150, negative_non_sentinel) + .phase_jitter, + 0.0, + "a non-sentinel negative phase still consumes the jitter sample" ); } #[test] - fn world_draw_range_count_restores_after_alpha_and_msh_facing_changes() { - let triangle = native_front_triangle(); - let (mesh, range) = facing_mesh(0x4000, &triangle); - let original_count = range.index_count; + fn op1_generic_diffuse_list_excludes_bit31_and_clears_inactive_lights() { + let light = |position, active| VulkanPointLight { + position, + rgb: [0.25, 0.5, 0.75], + range: 15.0, + coefficients: [1.0, 0.5, 0.25], + active, + }; + let raw_records = vec![ + (light([1.0, 2.0, 3.0], true), 0xA000_0000), + (light([4.0, 5.0, 6.0], true), 0x2000_0000), + (light([7.0, 8.0, 9.0], true), 0x8000_0000), + (light([10.0, 11.0, 12.0], false), 0x0000_0000), + ]; + let ordinary = ordinary_control_point_lights(&raw_records); + assert_eq!(ordinary.len(), 1); + assert_eq!(ordinary[0].position, [4.0, 5.0, 6.0]); + assert_eq!(raw_records.len(), 4); assert_eq!( - world_draw_range_index_count(&mesh, &range, original_count, 0.0, [0.0; 3], Some(1)), - 0 + raw_records + .iter() + .map(|(_, flags)| *flags) + .collect::<Vec<_>>(), + vec![0xA000_0000, 0x2000_0000, 0x8000_0000, 0] ); + + assert!(ordinary_control_point_lights(&[]).is_empty()); + assert!( + ordinary_control_point_lights(&[(light([13.0, 14.0, 15.0], false), 0x2000_0000)]) + .is_empty() + ); + } + + #[test] + fn op1_control_slot_accepts_supported_bodies_with_instance_random_flags() { + let command = endpoint_op1_command(6, 0.0, 1.0); + assert!(ambient_effect_op1_supported(&command)); + assert!(!ambient_effect_op1_supported(&endpoint_op1_command( + 4, 0.0, 1.0 + ))); + } + + #[test] + fn op1_phase_preserves_native_sentinel_and_initial_manager_gate() { + assert_eq!(native_command_phase(0, -1.0, 0.4), -1.0); + assert_eq!(native_command_phase(0x100, 0.625, 0.0), 0.625); + assert_eq!(native_command_phase(0x80, 0.625, 0.4), 0.0); + assert_eq!(native_command_phase(0x81, -1.0, 0.4), 0.4); + assert_eq!(native_command_phase(0, -0.25, 0.0), 0.0); + assert_eq!(native_command_phase(0, 1.25, 0.0), 1.0); + assert_eq!(native_command_phase(1, 0.5, 0.25), 0.75); + assert_eq!(native_command_phase(1, 0.9, 0.25), 1.0); + let flags_200_20 = + ambient_effect_phase(1, 0x220, 1.5, 1.0).expect("triangular post-duration phase"); + assert_eq!(flags_200_20, -2.5); + assert_eq!(native_command_phase(0x220, flags_200_20, 0.4), 0.0); + let raw_minus_one = + ambient_effect_phase(1, 0x20, 1.5, 1.0).expect("triangular sentinel phase"); + assert_eq!(raw_minus_one, -1.0); + assert_eq!(native_command_phase(0x21, raw_minus_one, 0.4), -1.0); + assert_eq!(native_command_phase(0xA1, raw_minus_one, 0.4), 0.4); + } + + #[test] + fn op7_variable_frames_clear_inactive_draw_ranges() { + let aligned = align_ambient_effect_frames( + vec![endpoint_effect_frame(3), endpoint_effect_frame(7)], + 4, + true, + ) + .expect("variable Op7 output fits its fixed range allocation"); + assert_eq!(aligned.len(), 4); assert_eq!( - world_draw_range_index_count( - &mesh, - &range, - original_count, - 1.0, - [0.0, 0.0, -2.0], - Some(1), - ), - 0 + aligned[0].as_ref().map(|frame| frame.command_index), + Some(3) ); assert_eq!( - world_draw_range_index_count(&mesh, &range, original_count, 1.0, [0.0; 3], Some(1)), - original_count + aligned[1].as_ref().map(|frame| frame.command_index), + Some(7) ); + assert!(aligned[2].is_none() && aligned[3].is_none()); + + assert!(align_ambient_effect_frames(vec![endpoint_effect_frame(0)], 4, false,).is_err()); + assert!(align_ambient_effect_frames(vec![endpoint_effect_frame(0); 5], 4, true,).is_err()); + } + + #[test] + fn active_control_fx_vertex_color_uses_sampled_mat0_additive_rgb() { + let mut slot = AmbientEffectGpuSlot { + kind: AmbientEffectKind::Op3Type2, + time_mode: 1, + header_flags: 0, + random_instance_key: ControlEffectInstanceKey { + object_index: 0, + component_index: 0, + table_index: 0, + row_index: 0, + }, + random_seed: 1, + phase_jitter: 0.0, + random_shift: [0.0; 3], + owner_object_index: 0, + owner_component_index: 0, + owner_node_index: 0, + owner_component_is_mounted: false, + root_transform: LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0; 3], + }, + owner_scale: [1.0; 3], + mount_pose: None, + local_from_effect: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + control_source: String::new(), + control_table_index: 0, + control_row_index: 0, + control_point_indices: [0; 3], + control_point_owners: [0; 3], + command: endpoint_op3_command(), + command_index: 0, + duration_seconds: 1.0, + world_from_effect: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + range: EnvironmentGpuRange { + range_index: 0, + vertex_start: 0, + vertex_capacity: 4, + material_index: 0, + }, + additional_particle_ranges: vec![EnvironmentGpuRange { + range_index: 1, + vertex_start: 4, + vertex_capacity: 4, + material_index: 1, + }], + material_animation_index: 0, + additional_particle_material_animation_indices: vec![1], + smoke_emitter_state: None, + op7_emitter_state: None, + }; assert_eq!( - world_draw_range_index_count( - &mesh, - &range, - original_count, - 1.0, - [0.0, 0.0, -2.0], - None, - ), - original_count + ambient_effect_ranges(&slot) + .iter() + .map(|(range, binding)| (range.range_index, *binding)) + .collect::<Vec<_>>(), + vec![(0, 0), (1, 1)] ); + let mut mesh = VulkanStaticMesh { + vertices: vec![environment_vertex([0.0; 3], [1.0; 3], [0.0; 2]); 8], + indices: Vec::new(), + draw_ranges: Vec::new(), + }; + let updates = [ + AmbientEffectGeometryUpdate { + range_index: 0, + index_count: 6, + command_rgb: [1.0; 3], + }, + AmbientEffectGeometryUpdate { + range_index: 1, + index_count: 6, + command_rgb: [1.0; 3], + }, + ]; + let mut active_materials = HashMap::from([ + ( + 0, + ActiveMaterialState { + material_index: 0, + phase_index: 0, + diffuse_alpha: 1.0, + additive_rgb: [0.9882353, 1.0, 0.16862746], + }, + ), + ( + 1, + ActiveMaterialState { + material_index: 1, + phase_index: 0, + diffuse_alpha: 1.0, + additive_rgb: [0.9882353, 1.0, 0.16862746], + }, + ), + ]); + + apply_ambient_effect_material_tints( + &[slot.clone()], + &mut mesh, + &updates, + &active_materials, + ) + .expect("apply sampled yellow MAT0 color"); + assert!(mesh + .vertices + .iter() + .all(|vertex| vertex.color == [0.9882353, 1.0, 0.16862746])); + + for material in active_materials.values_mut() { + material.additive_rgb = [0.1, 0.7, 0.2]; + } + slot.kind = AmbientEffectKind::SmokePool; + apply_ambient_effect_material_tints(&[slot], &mut mesh, &updates, &active_materials) + .expect("apply next sampled MAT0 color"); + assert!(mesh + .vertices + .iter() + .all(|vertex| vertex.color == [0.1, 0.7, 0.2])); + } + + #[test] + fn ambient_fx_uses_vertex_opacity_without_multiplying_mat0_opacity() { + let mat0_opacity = ambient_effect_material_alpha(true, 0.4); + let effect_opacity = 0.5; + let texture_alpha = 0.25; + assert_eq!(mat0_opacity, 1.0); + assert_eq!(mat0_opacity * effect_opacity, 0.5); + assert_eq!(texture_alpha * mat0_opacity * effect_opacity, 0.125); + assert_eq!(ambient_effect_material_alpha(false, 0.4), 0.4); + } + + #[test] + fn material_animation_cpu_packet_uses_sampled_phase_without_renderer() { + let phase = |additive_red| MaterialPhase { + parameters: [ + additive_red, + 0, + 0, + 100, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + texture_raw: [0; 16], + }; + let document = Mat0Document { + version: 0, + animation_block_count: 1, + phases: vec![phase(0), phase(255)], + prefix: Vec::new(), + header_opaque: [0; 2], + animation_blocks: vec![MaterialAnimationBlock { + header_raw: 0, + keys: vec![ + MaterialKey { + k0: 0, + k1: 500, + k2: 0, + }, + MaterialKey { + k0: 1, + k1: 1000, + k2: 0, + }, + ], + bytes: Vec::new(), + }], + }; + let mut binding = MaterialAnimationBinding { + initial_phase_index: 0, + normalized_phase_override: Some(0.75), + ambient_fx_material_alpha: true, + variants: Vec::new(), + base: MaterialPhaseBinding { + document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: 0, + frozen_phase_index: None, + phase_uv_transforms: vec![[0.0, 0.0, 1.0, 1.0]; 2], + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }; + + let effect_sample = sample_material_animation_base(&mut binding, 0) + .expect("sample the effect's native normalized MAT0 phase"); + assert_eq!(effect_sample.phase_index, 1); + assert_eq!(effect_sample.coefficients.additive_rgb, [1.0, 0.0, 0.0]); + + binding.normalized_phase_override = None; + let world_sample = sample_material_animation_base(&mut binding, 0) + .expect("sample ordinary clock-driven material phase"); + assert_eq!(world_sample.phase_index, 0); + assert_eq!(world_sample.coefficients.additive_rgb, [0.0, 0.0, 0.0]); + + binding.variants.push(MaterialVariantSet { + phase_material_indices: vec![0, 0], + range_indices: vec![7], + pipeline_category: 0, + }); + let mut materials = Vec::new(); + append_environment_gradient_material(&mut materials) + .expect("prepare test material without a renderer"); + let update = + sample_material_animations(std::slice::from_mut(&mut binding), &materials, 500) + .expect("produce a CPU material-frame packet without a renderer"); + assert_eq!(update.active_materials[&7].material_index, 0); + assert_eq!(update.active_materials[&7].phase_index, 1); + assert_eq!(update.range_selections[0].range_index, 7); + } + + #[test] + fn ambient_fx_tint_requires_a_proven_ambient_mat0_phase() { + assert!(ambient_effect_tint_supported( + &[false, false], + &[[0.0; 3], [0.2; 3]], + &[[1.0, 1.0, 1.0], [0.2, 0.4, 0.8]], + )); + assert!(!ambient_effect_tint_supported(&[], &[], &[])); + assert!(!ambient_effect_tint_supported(&[false], &[], &[])); + assert!(!ambient_effect_tint_supported( + &[true], + &[[0.0; 3]], + &[[1.0; 3]] + )); + assert!(!ambient_effect_tint_supported( + &[false], + &[[0.1, 0.0, 0.0]], + &[[0.0; 3]], + )); + assert!(!ambient_effect_tint_supported( + &[false], + &[[0.0; 3]], + &[[f32::NAN, 1.0, 1.0]] + )); + } + + #[test] + fn ambient_fx_ranges_are_excluded_from_world_alpha_and_count_updates() { + let fixed_ranges = HashSet::from([1, 4, 7]); + let world_ranges = world_draw_range_indices(9, &fixed_ranges); + assert_eq!(world_ranges, vec![0, 2, 3, 5, 6, 8]); + assert!(!world_ranges.contains(&7)); + } + + #[test] + fn ambient_control_rows_accept_unconditional_low_words_but_skip_state_gates_and_chains() { + let mut row = PreparedControlEffectRow { + table_index: 2, + row_index: 0, + raw_condition: [0, 7, 9], + point_offsets: [0, 1, 2], + selector: 0, + resource: fparkan_assets::PreparedControlResourcePair { + archive: [0; 32], + name: [0; 32], + }, + }; + assert!(ambient_effect_row_unconditionally_active(&row)); + for condition_flag in [0x1000_0000, 0x2000_0000, 0x4000_0000, 0x8000_0000] { + row.raw_condition[0] = condition_flag; + assert!(!ambient_effect_row_unconditionally_active(&row)); + } } } diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 2ee829f..a1ae4ec 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -10,8 +10,8 @@ pub use fparkan_material::{ sample_material_normalized_phase, sample_material_phase, Mat0Document, MaterialCoefficients, MaterialError, MaterialPhase, MaterialPhaseSample, }; -use fparkan_mission_format::{decode_tma, decode_tma_land_path, ClanBody}; -pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile}; +use fparkan_mission_format::{decode_tma, decode_tma_land_path}; +pub use fparkan_mission_format::{ClanBody, LpString, MissionDocument, MissionError, TmaProfile}; use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError}; use fparkan_nres::{decode as decode_nres, ReadProfile}; pub use fparkan_nres::{NresDocument, NresError}; @@ -28,6 +28,11 @@ pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError}; use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError}; pub use fparkan_texm::{PageRect, RgbaImage}; use fparkan_vfs::DirectoryVfs; +mod research; +pub use research::{ + calc_full_research_cost, ResearchCandidate, ResearchCandidateSet, ResearchCost, ResearchError, + ResearchTrfDocument, +}; use std::collections::{hash_map::Entry, HashMap}; use std::fmt; use std::hash::{Hash, Hasher}; @@ -1905,16 +1910,12 @@ impl MissionAssets { visual_count: self.visuals.len(), model_count: self.models.len(), material_count: self.materials.len(), - texture_count: self - .textures - .iter() - .filter(|texture| texture.usage == PreparedTextureUsage::Diffuse) - .count(), + texture_count: self.visuals.iter().map(|visual| visual.texture_count).sum(), lightmap_count: self - .textures + .visuals .iter() - .filter(|texture| texture.usage == PreparedTextureUsage::Lightmap) - .count(), + .map(|visual| visual.lightmap_count) + .sum(), } } } @@ -2111,6 +2112,33 @@ impl<R: ResourceRepository> AssetManager<R> { ) -> Result<MissionAssetPlan, AssetError> { build_mission_asset_plan_with_repository(&self.repository, prototypes) } + + /// Reads raw opcodes from the validated initial Control effect group. + /// + /// A complete no-effect section or an out-of-range raw initial-group + /// selector returns `Some(Vec::new())`; unsupported shapes return `None`. + /// + /// # Errors + /// + /// Returns [`AssetError`] when the Control resource is malformed. + pub fn initial_control_effect_opcodes( + &self, + source: &ResourceKey, + model_node_count: usize, + prototype_index: usize, + ) -> Result<Option<Vec<u32>>, AssetError> { + let Some(payload) = read_optional_key(&self.repository, source, Some("component control"))? + else { + return Ok(None); + }; + let (_, opcodes) = decode_component_control_payload_with_opcodes( + &payload, + source.clone(), + model_node_count, + prototype_index, + )?; + Ok(opcodes) + } } /// Produces a count-only plan from a prototype graph. @@ -3866,7 +3894,7 @@ fn control_i32_at(payload: &[u8], offset: usize) -> Option<i32> { } const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] = - &[1, 2, 3, 4, 5, 8, 9, 10, 12, 13, 15, 19, 21, 25, 26, 27]; + &[1, 2, 3, 4, 5, 8, 9, 10, 12, 13, 15, 17, 19, 21, 25, 26, 27]; const CONTROL_TYPED_RECORD_FIXED_BYTES: usize = 0xB0; const CONTROL_TYPED_RECORD_COUNT_OFFSET: usize = 0xAC; const CONTROL_TYPED_RECORD_LIMIT: usize = 4096; @@ -3880,6 +3908,7 @@ const CONTROL_EFFECT_ROW_BYTES: usize = 100; struct DecodedComponentEffectSection { section: PreparedControlEffectSection, initial_effect_group: Option<usize>, + initial_group_opcodes: Option<Vec<u32>>, typed_records: Vec<PreparedControlTypedRecord>, } @@ -3904,6 +3933,7 @@ where } }, initial_effect_group: None, + initial_group_opcodes: (cursor == payload.len()).then(Vec::new), typed_records: Vec::new(), }); } @@ -3925,6 +3955,7 @@ where typed_record_tag: Some(tag), }, initial_effect_group: None, + initial_group_opcodes: None, typed_records, }); } @@ -4014,8 +4045,9 @@ where } if effect_group_count == 0 { + let complete = cursor == payload.len(); return Ok(DecodedComponentEffectSection { - section: if cursor == payload.len() { + section: if complete { PreparedControlEffectSection::Parsed(Vec::new()) } else { PreparedControlEffectSection::Unsupported { @@ -4024,6 +4056,7 @@ where } }, initial_effect_group: None, + initial_group_opcodes: complete.then(Vec::new), typed_records, }); } @@ -4042,6 +4075,7 @@ where .filter(|group| *group < effect_group_count); cursor = group_header_end; + let mut initial_group_opcodes = Some(Vec::new()); let mut effect_rows = Vec::new(); for table_index in 0..effect_group_count { let row_count = usize::try_from( @@ -4068,7 +4102,14 @@ where for row_index in 0..row_count { let row_start = rows_start + row_index * CONTROL_EFFECT_ROW_BYTES; let row = &payload[row_start..row_start + CONTROL_EFFECT_ROW_BYTES]; - if control_u32_at(row, 0x0C) != Some(4) { + let opcode = control_u32_at(row, 0x0C).expect("fixed effect row has opcode"); + if initial_effect_group == Some(table_index) { + initial_group_opcodes + .as_mut() + .expect("valid initial group has an opcode list") + .push(opcode); + } + if opcode != 4 { continue; } let raw_condition = [ @@ -4107,12 +4148,14 @@ where typed_record_tag: None, }, initial_effect_group: None, + initial_group_opcodes: None, typed_records, }); } Ok(DecodedComponentEffectSection { section: PreparedControlEffectSection::Parsed(effect_rows), initial_effect_group, + initial_group_opcodes, typed_records, }) } @@ -4129,6 +4172,21 @@ fn decode_component_control_payload( model_node_count: usize, prototype_index: usize, ) -> Result<PreparedComponentControl, AssetError> { + decode_component_control_payload_with_opcodes( + payload, + source, + model_node_count, + prototype_index, + ) + .map(|(control, _)| control) +} + +fn decode_component_control_payload_with_opcodes( + payload: &[u8], + source: ResourceKey, + model_node_count: usize, + prototype_index: usize, +) -> Result<(PreparedComponentControl, Option<Vec<u32>>), AssetError> { const HEADER_BYTES: usize = 5 * 4; const FIXED_SECTION_BYTES: usize = 108; const SECTION_FIXED_BYTES: usize = 156; @@ -4266,18 +4324,22 @@ fn decode_component_control_payload( &invalid, )?; - Ok(PreparedComponentControl { - source, - native_profile_words, - model_node_count, - bindings, - timeline_rows, - typed_records: decoded_effect.typed_records, - node_data: None, - effect_section: decoded_effect.section, - initial_effect_group: decoded_effect.initial_effect_group, - point_list: None, - }) + let initial_group_opcodes = decoded_effect.initial_group_opcodes; + Ok(( + PreparedComponentControl { + source, + native_profile_words, + model_node_count, + bindings, + timeline_rows, + typed_records: decoded_effect.typed_records, + node_data: None, + effect_section: decoded_effect.section, + initial_effect_group: decoded_effect.initial_effect_group, + point_list: None, + }, + initial_group_opcodes, + )) } fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> { @@ -4376,7 +4438,8 @@ impl Hasher for StableHasher { #[cfg(test)] mod tests { use super::*; - use fparkan_prototype::build_prototype_graph; + use fparkan_binary::{sha256, sha256_hex}; + use fparkan_prototype::{build_prototype_graph, resolve_prototype_all}; use fparkan_resource::{resource_name, CachedResourceRepository}; use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs}; use std::path::PathBuf; @@ -4478,6 +4541,1476 @@ mod tests { )); } + #[test] + fn research_trf_reader_preserves_rows_and_uses_low_u16_alias_link() { + let bytes = research_cycle_fixture(3, &[0; 5]); + let document = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .expect("synthetic TRF"); + + assert_eq!(document.row_count(), 5); + assert_eq!(document.find_key(b"a").expect("key lookup"), Some(0)); + let row = document.row_words(0).expect("first raw row"); + assert_eq!(row[0], 10.0_f32.to_bits()); + assert_eq!(row[1], 1.0_f32.to_bits()); + assert_eq!(row[8], 0x09FF_0000); + assert_eq!(document.row_words(2).expect("third row")[8], 0x09FF_0002); + assert_eq!(document.flag_byte(0), Some(0)); + assert_eq!(document.relation_2_3(0).expect("ordered links"), [3, 1]); + assert!(document + .relation_4_5(0) + .expect("empty optional stream") + .is_empty()); + } + + #[test] + fn research_trf_reader_rejects_the_native_trf0_attribute_guard() { + let bytes = research_cycle_fixture(4, &[0; 5]); + + assert!(ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())).is_err()); + } + + #[test] + fn research_cost_matches_native_cycle_dedup_nonzero_result() { + let bytes = research_cycle_fixture(3, &[0; 5]); + let research = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .expect("synthetic TRF"); + let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]); + + assert_eq!( + calc_full_research_cost(&candidate, &research).expect("cost"), + ResearchCost { + ore: 15.0, + power: 150.0, + } + ); + } + + #[test] + fn research_candidate_set_selects_later_lower_cost_and_updates_native_statuses() { + let costs = [(200.0, 0.0), (40.0, 0.0)]; + let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)]; + let empty_groups = [&[][..], &[][..]]; + let bytes = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0x05, 0x05], + costs: &costs, + aliases: &aliases, + row_alias_links: &[0, 1], + relations_23: &empty_groups, + relations_45: &empty_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let research = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .expect("synthetic research table"); + let first_path = parse_path("units/first.dat").expect("first candidate path"); + let second_path = parse_path("units/second.dat").expect("second candidate path"); + let mut vfs = MemoryVfs::default(); + vfs.insert( + first_path.clone(), + Arc::from(research_unit_dat_payload(&[b"A"], &[0], &[1]).into_boxed_slice()), + ); + vfs.insert( + second_path.clone(), + Arc::from(research_unit_dat_payload(&[b"B"], &[0], &[1]).into_boxed_slice()), + ); + let mut candidates = ResearchCandidateSet { + entries: vec![ + ResearchCandidate { + path: first_path, + status: 0, + }, + ResearchCandidate { + path: second_path, + status: 0, + }, + ], + selected_index: 0x1234_5678, + loaded: true, + status: 0, + }; + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("refresh research candidates"); + + assert_eq!( + candidates.selected_index, 1, + "later lower-cost candidate wins" + ); + assert_eq!( + candidates.status, 0, + "candidate scan status is cleared before reads" + ); + assert_eq!( + candidates + .entries + .iter() + .map(|entry| entry.status) + .collect::<Vec<_>>(), + [0, 0], + "both eligible candidates pass predicate two with false status" + ); + } + + #[test] + fn research_candidate_set_guards_and_empty_catalog_match_native_state() { + let research = research_selector_document(&[0x05], &[(10.0, 0.0)], &[(b"A", 0)], &[0]); + let default = ResearchCandidateSet::default(); + assert!(default.entries.is_empty()); + assert_eq!(default.selected_index, 0); + assert!(!default.loaded); + assert_eq!(default.status, 0); + let mut candidates = research_candidate_set(&[("missing.dat", 7)], 41, false, 9); + candidates + .refresh_selection(&MemoryVfs::default(), Some(&research)) + .expect("unloaded catalog is a no-op"); + assert_eq!(candidates.selected_index, 41); + assert_eq!(candidates.status, 9); + assert_eq!(candidates.entries[0].status, 7); + + candidates.loaded = true; + candidates + .refresh_selection(&MemoryVfs::default(), None) + .expect("missing context is a no-op"); + assert_eq!(candidates.selected_index, 41); + assert_eq!(candidates.status, 9); + assert_eq!(candidates.entries[0].status, 7); + + let mut empty = research_candidate_set(&[], 63, true, 0); + empty + .refresh_selection(&MemoryVfs::default(), Some(&research)) + .expect("loaded empty catalog refresh"); + assert_eq!(empty.selected_index, 63); + assert_eq!(empty.status, 1); + } + + #[test] + fn research_candidate_set_skips_nonzero_status_without_reading_missing_path() { + let research = research_selector_document(&[0x05], &[(10.0, 0.0)], &[(b"A", 0)], &[0]); + let mut candidates = research_candidate_set(&[("missing.dat", 1)], 52, true, 0); + + candidates + .refresh_selection(&MemoryVfs::default(), Some(&research)) + .expect("nonzero entry status skips VFS read"); + + assert_eq!(candidates.selected_index, 52); + assert_eq!(candidates.status, 1); + assert_eq!(candidates.entries[0].status, 1); + } + + #[test] + fn research_candidate_set_short_circuits_false_predicates_on_parsed_dat() { + let research = research_selector_document(&[0x03], &[(10.0, 0.0)], &[(b"A", 0)], &[0]); + let first_path = "units/predicate-false.dat"; + let second_path = "units/missing-key.dat"; + let mut vfs = MemoryVfs::default(); + insert_research_dat( + &mut vfs, + first_path, + research_unit_dat_payload(&[b"A"], &[0], &[1]), + ); + insert_research_dat( + &mut vfs, + second_path, + research_unit_dat_payload(&[b"Z"], &[0], &[1]), + ); + let mut candidates = + research_candidate_set(&[(first_path, 0), (second_path, 0)], 0x1234_5678, true, 0); + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("false native predicates skip without cost errors"); + + assert_eq!(candidates.selected_index, 0x1234_5678); + assert_eq!(candidates.status, 0); + assert_eq!( + candidates + .entries + .iter() + .map(|entry| entry.status) + .collect::<Vec<_>>(), + [0, 0] + ); + } + + #[test] + fn research_candidate_set_prunes_kind_zero_subtree_before_non_ascii_lookup() { + let research = research_selector_document(&[0x05], &[(10.0, 0.0)], &[(b"A", 0)], &[0]); + let path = "units/pruned-non-ascii-grandchild.dat"; + let non_ascii = [0xC0]; + let keys: [&[u8]; 3] = [b"A", b"", &non_ascii]; + let mut vfs = MemoryVfs::default(); + insert_research_dat( + &mut vfs, + path, + research_unit_dat_payload(&keys, &[1, 1, 0], &[1, 0, 1]), + ); + let mut candidates = research_candidate_set(&[(path, 0)], 79, true, 0); + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("kind-zero subtree is pruned before its non-ASCII key"); + + assert_eq!(candidates.selected_index, 0); + assert_eq!(candidates.status, 0); + assert_eq!(candidates.entries[0].status, 0); + } + + #[test] + fn research_candidate_set_keeps_second_predicate_true_status_and_selection() { + let research = research_selector_document(&[0x07], &[(10.0, 0.0)], &[(b"A", 0)], &[0]); + let path = "units/second-predicate-true.dat"; + let mut vfs = MemoryVfs::default(); + insert_research_dat( + &mut vfs, + path, + research_unit_dat_payload(&[b"A"], &[0], &[1]), + ); + let mut candidates = research_candidate_set(&[(path, 0)], 73, true, 0); + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("second predicate true is a candidate skip"); + + assert_eq!(candidates.selected_index, 73); + assert_eq!(candidates.status, 0); + assert_eq!(candidates.entries[0].status, 1); + } + + #[test] + fn research_candidate_set_skips_read_and_parse_failures_then_selects_later_candidate() { + let research = research_selector_document( + &[0x05, 0x05], + &[(200.0, 0.0), (40.0, 0.0)], + &[(b"A", 0), (b"B", 1)], + &[0, 1], + ); + let wrong_magic = "units/wrong-magic.dat"; + let excessive_children = "units/excessive-children.dat"; + let winner = "units/winner.dat"; + let mut vfs = MemoryVfs::default(); + let mut wrong_magic_bytes = research_unit_dat_payload(&[b"A"], &[0], &[1]); + wrong_magic_bytes[..4].copy_from_slice(&0x1234_5678_u32.to_le_bytes()); + insert_research_dat(&mut vfs, wrong_magic, wrong_magic_bytes); + let mut excessive_children_bytes = research_unit_dat_payload(&[b"A"], &[0], &[1]); + excessive_children_bytes[8 + 108..8 + 112].copy_from_slice(&u32::MAX.to_le_bytes()); + insert_research_dat(&mut vfs, excessive_children, excessive_children_bytes); + insert_research_dat( + &mut vfs, + winner, + research_unit_dat_payload(&[b"B"], &[0], &[1]), + ); + let mut candidates = research_candidate_set( + &[ + ("units/missing.dat", 0), + (wrong_magic, 0), + (excessive_children, 0), + (winner, 0), + ], + 91, + true, + 0, + ); + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("read and parse failures are skipped"); + + assert_eq!(candidates.selected_index, 3); + assert_eq!(candidates.status, 0); + assert_eq!( + candidates + .entries + .iter() + .map(|entry| entry.status) + .collect::<Vec<_>>(), + [0, 0, 0, 0] + ); + } + + #[test] + fn research_candidate_set_uses_strict_bound_and_keeps_first_tie() { + let tie_research = research_selector_document( + &[0x05, 0x05], + &[(40.0, 0.0), (40.0, 0.0)], + &[(b"A", 0), (b"B", 1)], + &[0, 1], + ); + let mut tie_vfs = MemoryVfs::default(); + insert_research_dat( + &mut tie_vfs, + "units/tie-a.dat", + research_unit_dat_payload(&[b"A"], &[0], &[1]), + ); + insert_research_dat( + &mut tie_vfs, + "units/tie-b.dat", + research_unit_dat_payload(&[b"B"], &[0], &[1]), + ); + let mut tied = research_candidate_set( + &[("units/tie-a.dat", 0), ("units/tie-b.dat", 0)], + 99, + true, + 0, + ); + tied.refresh_selection(&tie_vfs, Some(&tie_research)) + .expect("strict comparison keeps first tied candidate"); + assert_eq!(tied.selected_index, 0); + + for ore in [f32::from_bits(0x4b18_967f), f32::NAN] { + let research = research_selector_document(&[0x05], &[(ore, 0.0)], &[(b"A", 0)], &[0]); + let mut vfs = MemoryVfs::default(); + insert_research_dat( + &mut vfs, + "units/no-improvement.dat", + research_unit_dat_payload(&[b"A"], &[0], &[1]), + ); + let mut candidates = + research_candidate_set(&[("units/no-improvement.dat", 0)], 0xABCD, true, 0); + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("equal bound and NaN cost do not select"); + assert_eq!(candidates.selected_index, 0xABCD); + assert_eq!(candidates.entries[0].status, 0); + } + } + + #[test] + fn research_candidate_set_ignores_tail_after_first_root_tree() { + let research = research_selector_document( + &[0x05, 0x05], + &[(200.0, 0.0), (40.0, 0.0)], + &[(b"A", 0), (b"B", 1)], + &[0, 1], + ); + let trailing = "units/trailing-three-bytes.dat"; + let orphan = "units/orphan-record.dat"; + let mut vfs = MemoryVfs::default(); + let mut trailing_bytes = research_unit_dat_payload(&[b"A"], &[0], &[1]); + trailing_bytes[4..8].copy_from_slice(&0xA5A5_5A5A_u32.to_le_bytes()); + trailing_bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]); + let (decoded, tree) = research::decode_candidate_first_tree(&trailing_bytes) + .expect("first complete root with trailing bytes"); + assert_eq!( + &decoded.header_opaque[4..8], + &0xA5A5_5A5A_u32.to_le_bytes(), + "second header DWORD remains opaque" + ); + assert_eq!(decoded.records.len(), 1); + assert_eq!(tree.len(), 1); + insert_research_dat(&mut vfs, trailing, trailing_bytes); + + let mut orphan_bytes = research_unit_dat_payload(&[b"B"], &[0], &[1]); + let orphan_record = research_unit_dat_payload(&[b"Z"], &[0], &[1]); + orphan_bytes.extend_from_slice(&orphan_record[8..]); + insert_research_dat(&mut vfs, orphan, orphan_bytes); + let mut candidates = research_candidate_set(&[(trailing, 0), (orphan, 0)], 99, true, 0); + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("suffix records are outside the first root tree"); + + assert_eq!(candidates.selected_index, 1); + assert_eq!(candidates.status, 0); + } + + #[test] + fn research_candidate_set_preserves_incremental_state_on_non_ascii_error() { + let non_ascii = [0xC0]; + let research = research_selector_document( + &[0x05, 0x05], + &[(10.0, 0.0), (20.0, 0.0)], + &[(b"A", 0), (&non_ascii, 1)], + &[0, 1], + ); + let mut vfs = MemoryVfs::default(); + insert_research_dat( + &mut vfs, + "units/research-a.dat", + research_unit_dat_payload(&[b"A"], &[0], &[1]), + ); + insert_research_dat( + &mut vfs, + "units/research-non-ascii.dat", + research_unit_dat_payload(&[&non_ascii], &[0], &[1]), + ); + let mut candidates = research_candidate_set( + &[ + ("units/research-a.dat", 0), + ("units/research-non-ascii.dat", 0), + ], + 0xAABB_CCDD, + true, + 1, + ); + + assert!(matches!( + candidates.refresh_selection(&vfs, Some(&research)), + Err(ResearchError::UnsupportedNonAsciiLookup { .. }) + )); + + assert_eq!(candidates.selected_index, 0); + assert_eq!(candidates.status, 0); + assert_eq!( + candidates + .entries + .iter() + .map(|entry| entry.status) + .collect::<Vec<_>>(), + [0, 0] + ); + } + + #[test] + fn research_candidate_set_skips_empty_table_cost_after_pruned_tree() { + let research = research_selector_document(&[], &[], &[], &[]); + let path = "units/empty-table-kind-zero.dat"; + let mut vfs = MemoryVfs::default(); + insert_research_dat( + &mut vfs, + path, + research_unit_dat_payload(&[b""], &[0], &[0]), + ); + let mut candidates = research_candidate_set(&[(path, 0)], 37, true, 0); + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("native cost failure skips this candidate"); + + assert_eq!(candidates.selected_index, 37); + assert_eq!(candidates.status, 0); + assert_eq!(candidates.entries[0].status, 1); + } + + #[test] + fn research_trf_reader_preserves_first_alias_raw_link_and_unused_tails() { + let costs = [(1.0, 10.0), (2.0, 20.0)]; + let aliases = [(b"Key".as_slice(), 0), (b"kEy".as_slice(), 1)]; + let row_alias_links = [1, 0]; + let empty_groups = [&[][..], &[][..]]; + let bytes = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0x84, 0x05], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &empty_groups, + relations_45: &empty_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let document = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .expect("synthetic TRF with unused payload tails"); + + assert_eq!(document.row_count(), 2); + assert_eq!(document.find_key(b"KEY").expect("alias lookup"), Some(0)); + assert_eq!(document.find_key(b"missing").expect("missing lookup"), None); + assert_eq!(document.row_name(0).expect("raw low-u16 link"), b"kEy"); + assert_eq!(document.row_words(0).expect("raw row")[8], 0x09FF_0001); + assert_eq!(document.flag_byte(0), Some(0x84)); + assert_eq!(document.relation_2_3(0).expect("empty group"), &[]); + assert_eq!(document.relation_4_5(0).expect("empty second group"), &[]); + + let trf0 = document + .archive() + .entries() + .iter() + .find(|entry| entry.meta().type_id == u32::from_le_bytes(*b"TRF0")) + .expect("retained TRF0 chunk"); + assert!(document + .archive() + .payload(trf0.id()) + .expect("retained payload") + .ends_with(RESEARCH_TEST_TAIL)); + } + + #[test] + fn research_trf_reader_rejects_required_chunk_and_attribute_errors() { + let cycle_costs = [ + (1.0, 10.0), + (2.0, 20.0), + (3.0, 30.0), + (4.0, 40.0), + (5.0, 50.0), + ]; + let names: [&[u8]; 5] = [b"A", b"B", b"C", b"D", b"E"]; + let aliases: Vec<_> = names + .iter() + .enumerate() + .map(|(index, name)| (*name, u16::try_from(index).expect("row id"))) + .collect(); + let row_alias_links = [0, 1, 2, 3, 4]; + let no_groups = [&[][..]; 5]; + + let invalid_attr = research_fixture(ResearchFixtureSpec { + trf0_attr2: 4, + flags: &[0; 5], + costs: &cycle_costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(invalid_attr.into_boxed_slice())), + Err(ResearchError::InvalidTrf0Attribute { got: 4 }) + )); + + let missing_required = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 5], + costs: &cycle_costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[u32::from_le_bytes(*b"TRF8")], + }); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(missing_required.into_boxed_slice())), + Err(ResearchError::MissingChunk { type_id }) + if type_id == u32::from_le_bytes(*b"TRF8") + )); + } + + #[test] + fn research_trf_reader_zero_fills_short_flags_and_rejects_long_flags() { + let costs = [(1.0, 10.0), (2.0, 20.0)]; + let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)]; + let row_alias_links = [0, 1]; + let no_groups = [&[][..], &[][..]]; + let short = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0x84], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let document = ResearchTrfDocument::decode(Arc::from(short.into_boxed_slice())) + .expect("short TRF1 is zero-filled by native loader"); + assert_eq!(document.flag_byte(0), Some(0x84)); + assert_eq!(document.flag_byte(1), Some(0)); + + let long = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 3], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(long.into_boxed_slice())), + Err(ResearchError::FlagsTooLong { + row_count: 2, + actual: 3 + }) + )); + } + + #[test] + fn research_trf_reader_checks_alias_and_pair_bounds_and_non_ascii_policy() { + let costs = [(1.0, 10.0), (2.0, 20.0)]; + let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)]; + let row_alias_links = [0, 1]; + let no_groups = [&[][..], &[][..]]; + let make_bytes = || { + research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }) + }; + + let mut invalid_name_offset = make_bytes(); + research_set_payload_u16( + &mut invalid_name_offset, + u32::from_le_bytes(*b"TRFB"), + 0, + u16::MAX, + ); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(invalid_name_offset.into_boxed_slice())), + Err(ResearchError::InvalidNameOffset { + pair_index: 0, + offset: u16::MAX + }) + )); + + let mut unterminated = make_bytes(); + research_set_payload_byte(&mut unterminated, u32::from_le_bytes(*b"TRF6"), 1, b'X'); + research_set_payload_byte(&mut unterminated, u32::from_le_bytes(*b"TRF6"), 3, b'X'); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(unterminated.into_boxed_slice())), + Err(ResearchError::UnterminatedName { + pair_index: 0, + offset: 0 + }) + )); + + let mut invalid_record_pair = make_bytes(); + research_set_payload_u32( + &mut invalid_record_pair, + u32::from_le_bytes(*b"TRF0"), + 32, + 0x09FF_0002, + ); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(invalid_record_pair.into_boxed_slice())), + Err(ResearchError::InvalidRecordPair { + record_index: 0, + pair_index: 2, + pair_count: 2 + }) + )); + + let mut invalid_alias_row = make_bytes(); + research_set_payload_u16(&mut invalid_alias_row, u32::from_le_bytes(*b"TRFB"), 2, 7); + let document = ResearchTrfDocument::decode(Arc::from(invalid_alias_row.into_boxed_slice())) + .expect("alias row is checked when selected"); + assert!(matches!( + document.find_key(b"A"), + Err(ResearchError::InvalidResearchIndex { + type_id, + index: 7, + row_count: 2 + }) if type_id == u32::from_le_bytes(*b"TRFB") + )); + + let non_ascii_aliases = [(b"\xC4".as_slice(), 0), (b"A".as_slice(), 1)]; + let non_ascii = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &non_ascii_aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let document = ResearchTrfDocument::decode(Arc::from(non_ascii.into_boxed_slice())) + .expect("non-ASCII names are preserved"); + assert!(matches!( + document.find_key(b"A"), + Err(ResearchError::UnsupportedNonAsciiLookup { .. }) + )); + assert!(matches!( + document.find_key(b"\xE4"), + Err(ResearchError::UnsupportedNonAsciiLookup { .. }) + )); + } + + #[test] + fn research_trf_reader_checks_optional_streams_group_bounds_and_relation_ids() { + let costs = [(1.0, 10.0), (2.0, 20.0)]; + let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)]; + let row_alias_links = [0, 1]; + let no_groups = [&[][..], &[][..]]; + let missing_zero_streams = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: false, + include_trf5: false, + omitted_chunks: &[], + }); + let document = + ResearchTrfDocument::decode(Arc::from(missing_zero_streams.into_boxed_slice())) + .expect("missing optional streams with zero IDs are empty"); + assert_eq!( + document.relation_2_3(0).expect("empty optional stream"), + &[] + ); + assert_eq!( + document.relation_4_5(0).expect("empty optional stream"), + &[] + ); + + let positive_group = [&[1_u32][..], &[][..]]; + let missing_trf3 = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &positive_group, + relations_45: &no_groups, + include_trf3: false, + include_trf5: true, + omitted_chunks: &[], + }); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(missing_trf3.into_boxed_slice())), + Err(ResearchError::MissingRelationStream { type_id, required_ids: 1 }) + if type_id == u32::from_le_bytes(*b"TRF3") + )); + + let out_of_range_23 = [&[7_u32][..], &[][..]]; + let out_of_range_45 = [&[][..], &[9_u32][..]]; + let invalid_ids = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &out_of_range_23, + relations_45: &out_of_range_45, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let document = ResearchTrfDocument::decode(Arc::from(invalid_ids.into_boxed_slice())) + .expect("indices are checked when a group is accessed"); + assert!(matches!( + document.relation_2_3(0), + Err(ResearchError::InvalidResearchIndex { type_id, index: 7, row_count: 2 }) + if type_id == u32::from_le_bytes(*b"TRF3") + )); + assert!(matches!( + document.relation_4_5(1), + Err(ResearchError::InvalidResearchIndex { type_id, index: 9, row_count: 2 }) + if type_id == u32::from_le_bytes(*b"TRF5") + )); + assert!(matches!( + document.relation_2_3(usize::MAX), + Err(ResearchError::MissingRelationGroup { type_id, .. }) + if type_id == u32::from_le_bytes(*b"TRF2") + )); + + let one_group = [&[][..]]; + let short_table = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &one_group, + relations_45: &one_group, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let document = ResearchTrfDocument::decode(Arc::from(short_table.into_boxed_slice())) + .expect("relation tables have their own counts"); + assert!(matches!( + document.relation_2_3(1), + Err(ResearchError::MissingRelationGroup { type_id, index: 1, group_count: 1 }) + if type_id == u32::from_le_bytes(*b"TRF2") + )); + assert!(matches!( + document.relation_4_5(1), + Err(ResearchError::MissingRelationGroup { type_id, index: 1, group_count: 1 }) + if type_id == u32::from_le_bytes(*b"TRF4") + )); + } + + #[test] + fn research_trf_reader_checks_count_prefixes_without_large_allocations() { + let costs = [(1.0, 10.0), (2.0, 20.0)]; + let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)]; + let row_alias_links = [0, 1]; + let no_groups = [&[][..], &[][..]]; + let mut bytes = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + research_set_nres_attr1(&mut bytes, u32::from_le_bytes(*b"TRF2"), 3); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())), + Err(ResearchError::Binary( + fparkan_binary::DecodeError::UnexpectedEof { .. } + )) + )); + } + + #[test] + fn research_cost_preserves_native_tree_order_and_f32_rounding() { + let costs = [(16_777_216.0, 20.0), (-16_777_216.0, 30.0), (1.0, 10.0)]; + let aliases_forward = [ + (b"A".as_slice(), 2), + (b"B".as_slice(), 0), + (b"C".as_slice(), 1), + ]; + let aliases_reversed = [ + (b"A".as_slice(), 0), + (b"B".as_slice(), 1), + (b"C".as_slice(), 2), + ]; + let row_alias_links = [0, 1, 2]; + let no_groups = [&[][..], &[][..], &[][..]]; + let make_research = |aliases: &[(&[u8], u16)]| { + let bytes = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0x05; 3], + costs: &costs, + aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())).expect("f32 order TRF") + }; + let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]); + + let forward = calc_full_research_cost(&candidate, &make_research(&aliases_forward)) + .expect("forward order cost"); + assert_eq!(forward.ore.to_bits(), 0.0_f32.to_bits()); + assert_eq!(forward.power.to_bits(), 60.0_f32.to_bits()); + + let reversed = calc_full_research_cost(&candidate, &make_research(&aliases_reversed)) + .expect("reversed order cost"); + assert_eq!(reversed.ore.to_bits(), 1.0_f32.to_bits()); + assert_eq!(reversed.power.to_bits(), 60.0_f32.to_bits()); + } + + #[test] + fn research_cost_obeys_subtree_and_record_gates() { + let costs = [ + (1.0, 10.0), + (2.0, 20.0), + (3.0, 30.0), + (4.0, 40.0), + (5.0, 50.0), + ]; + let names: [&[u8]; 5] = [b"A", b"B", b"C", b"D", b"E"]; + let aliases: Vec<_> = names + .iter() + .enumerate() + .map(|(index, name)| (*name, u16::try_from(index).expect("row id"))) + .collect(); + let row_alias_links = [0, 1, 2, 3, 4]; + let relations_45 = [&[][..]; 5]; + let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]); + + let recursion_gate = research_cycle_fixture(3, &[0x05, 0x05, 0x05, 0x04, 0x04]); + let research = ResearchTrfDocument::decode(Arc::from(recursion_gate.into_boxed_slice())) + .expect("recursion-gate TRF"); + assert_eq!( + calc_full_research_cost(&candidate, &research).expect("cost"), + ResearchCost { + ore: 6.0, + power: 60.0 + } + ); + + let include_gate = research_cycle_fixture(3, &[0x04, 0x05, 0x05, 0x06, 0x04]); + let research = ResearchTrfDocument::decode(Arc::from(include_gate.into_boxed_slice())) + .expect("include-gate TRF"); + assert_eq!( + calc_full_research_cost(&candidate, &research).expect("cost"), + ResearchCost { + ore: 6.0, + power: 60.0 + } + ); + + let no_groups = [&[][..]; 5]; + let isolated = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0x04; 5], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &relations_45, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let research = ResearchTrfDocument::decode(Arc::from(isolated.into_boxed_slice())) + .expect("isolated TRF"); + let child_pruned = research_unit_dat(&[b"A", b"B", b"C"], &[1, 1, 0], &[1, 0, 1]); + assert_eq!( + calc_full_research_cost(&child_pruned, &research).expect("pruned child cost"), + ResearchCost { + ore: 1.0, + power: 10.0 + } + ); + let root_pruned = research_unit_dat(&[b"A", b"B"], &[1, 0], &[0, 1]); + assert_eq!( + calc_full_research_cost(&root_pruned, &research).expect("pruned root cost"), + ResearchCost { + ore: 0.0, + power: 0.0 + } + ); + } + + #[test] + fn research_cost_rejects_unknown_keys_unterminated_keys_and_missing_groups() { + let costs = [(1.0, 10.0), (2.0, 20.0)]; + let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)]; + let row_alias_links = [0, 1]; + let no_groups = [&[][..], &[][..]]; + let make_document = |groups: &[&[u32]]| { + let bytes = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0x04; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .expect("research table") + }; + + let no_group_document = make_document(&[]); + let candidate = research_unit_dat(&[b"A"], &[0], &[1]); + assert!(matches!( + calc_full_research_cost(&candidate, &no_group_document), + Err(ResearchError::MissingRelationGroup { + type_id, + index: 0, + group_count: 0 + }) if type_id == u32::from_le_bytes(*b"TRF2") + )); + + let valid_groups = [&[][..], &[][..]]; + let document = make_document(&valid_groups); + let unknown = research_unit_dat(&[b"Z"], &[0], &[1]); + assert!(matches!( + calc_full_research_cost(&unknown, &document), + Err(ResearchError::UnknownResearchKey { + component_index: 0, + .. + }) + )); + let unterminated = research_unit_dat(&[&[b'A'; 32]], &[0], &[1]); + assert!(matches!( + calc_full_research_cost(&unterminated, &document), + Err(ResearchError::MissingCandidateKeyTerminator { component_index: 0 }) + )); + } + + #[test] + #[ignore = "requires licensed Part 1 corpus"] + fn licensed_scream_trf_and_12hel1_dat_exercise_research_cost() { + let root = fixture_root("IS"); + let trf = + std::fs::read(root.join("MISSIONS/SCRIPTS/scream.trf")).expect("licensed scream.trf"); + let research = ResearchTrfDocument::decode(Arc::from(trf.into_boxed_slice())) + .expect("decode licensed research table"); + assert_eq!(research.row_count(), 368); + assert_eq!(research.find_key(b"R_T_02").expect("R_T_02"), Some(205)); + assert_eq!(research.row_name(205).expect("row 205 name"), b"R_T_02"); + assert_eq!( + &research.row_words(205).expect("row 205 words")[..4], + &[0, 0, 0x40A0_0000, 0x4140_0000] + ); + assert_eq!(research.flag_byte(205), Some(0x07)); + assert!(research + .relation_2_3(205) + .expect("row 205 TRF2/TRF3 group") + .is_empty()); + assert_eq!( + research.relation_4_5(205).expect("row 205 second group"), + &[196] + ); + + let bytes = + std::fs::read(root.join("UNITS/UNITS/AI/12hel1.dat")).expect("licensed 12hel1.dat"); + let candidate = fparkan_prototype::decode_unit_dat(&bytes).expect("decode Unit DAT"); + calc_full_research_cost(&candidate, &research).expect("calculate candidate research cost"); + } + + #[test] + #[ignore = "requires licensed Part 1 corpus"] + fn licensed_part1_ai_candidate_prefixes_and_ordered_research_scan() { + let root = fixture_root("IS"); + let vfs = DirectoryVfs::new(&root); + let table_path = parse_path("MISSIONS/SCRIPTS/scream.trf").expect("research path"); + let table_bytes = vfs.read(&table_path).expect("licensed scream.trf"); + let research = ResearchTrfDocument::decode(table_bytes).expect("decode scream.trf"); + let prefix = parse_path("UNITS/UNITS/AI").expect("AI unit directory"); + let mut candidate_paths = vfs + .list(&prefix) + .expect("list Part 1 AI assets") + .into_iter() + .filter_map(|entry| { + entry + .path + .as_bytes() + .to_ascii_lowercase() + .ends_with(b".dat") + .then_some(entry.path) + }) + .collect::<Vec<_>>(); + candidate_paths.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes())); + assert!( + !candidate_paths.is_empty(), + "Part 1 AI catalog has DAT files" + ); + assert!(candidate_paths + .iter() + .all(|path| path.as_bytes().is_ascii())); + assert!(candidate_paths + .windows(2) + .all(|pair| pair[0].as_bytes() <= pair[1].as_bytes())); + + for path in &candidate_paths { + let bytes = vfs + .read(path) + .unwrap_or_else(|error| panic!("{}: {error}", path.as_str())); + research::decode_candidate_first_tree(&bytes) + .unwrap_or_else(|error| panic!("{}: {error}", path.as_str())); + } + + let mut candidates = ResearchCandidateSet { + entries: candidate_paths + .iter() + .cloned() + .map(|path| ResearchCandidate { path, status: 0 }) + .collect(), + selected_index: 0, + loaded: true, + status: 0xA5, + }; + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("scan Part 1 AI candidates in byte-sorted supplied order"); + assert!( + usize::try_from(candidates.selected_index) + .is_ok_and(|index| index < candidate_paths.len()), + "selected index remains within the supplied catalog" + ); + assert_eq!( + candidates.status, 0, + "at least one catalog entry was scanned" + ); + } + + #[test] + #[ignore = "requires licensed Part 1 corpus"] + fn licensed_part1_research_trf_files_all_decode() { + fn collect_trf_files(directory: &std::path::Path, files: &mut Vec<PathBuf>) { + for entry in std::fs::read_dir(directory).expect("read licensed directory") { + let entry = entry.expect("read directory entry"); + let path = entry.path(); + let kind = entry.file_type().expect("read entry type"); + if kind.is_dir() { + collect_trf_files(&path, files); + } else if !kind.is_symlink() + && path + .extension() + .map(|extension| extension.to_string_lossy().eq_ignore_ascii_case("trf")) + .unwrap_or(false) + { + files.push(path); + } + } + } + + let root = fixture_root("IS"); + let mut files = Vec::new(); + collect_trf_files(&root, &mut files); + files.sort(); + assert!(!files.is_empty(), "licensed corpus contains no .trf files"); + for path in files { + let bytes = std::fs::read(&path).expect("read TRF resource"); + ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + } + } + + const RESEARCH_TEST_TAIL: &[u8] = &[0xA5, 0x5A]; + + struct ResearchFixtureSpec<'a> { + trf0_attr2: u32, + flags: &'a [u8], + costs: &'a [(f32, f32)], + aliases: &'a [(&'a [u8], u16)], + row_alias_links: &'a [u16], + relations_23: &'a [&'a [u32]], + relations_45: &'a [&'a [u32]], + include_trf3: bool, + include_trf5: bool, + omitted_chunks: &'a [u32], + } + + fn research_selector_document( + flags: &[u8], + costs: &[(f32, f32)], + aliases: &[(&[u8], u16)], + row_alias_links: &[u16], + ) -> ResearchTrfDocument { + let empty_groups = vec![&[][..]; costs.len()]; + let bytes = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags, + costs, + aliases, + row_alias_links, + relations_23: &empty_groups, + relations_45: &empty_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .expect("synthetic research selector table") + } + + fn research_candidate_set( + entries: &[(&str, u8)], + selected_index: u32, + loaded: bool, + status: u8, + ) -> ResearchCandidateSet { + ResearchCandidateSet { + entries: entries + .iter() + .map(|(path, status)| ResearchCandidate { + path: parse_path(path).expect("candidate path"), + status: *status, + }) + .collect(), + selected_index, + loaded, + status, + } + } + + fn insert_research_dat(vfs: &mut MemoryVfs, path: &str, bytes: Vec<u8>) { + vfs.insert( + parse_path(path).expect("candidate path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + + fn research_fixture(spec: ResearchFixtureSpec<'_>) -> Vec<u8> { + assert_eq!(spec.costs.len(), spec.row_alias_links.len()); + + let mut rows = Vec::with_capacity(spec.costs.len()); + for ((ore, power), pair_index) in spec.costs.iter().zip(spec.row_alias_links) { + let mut row = [0_u32; 10]; + row[0] = power.to_bits(); + row[1] = ore.to_bits(); + row[8] = 0x09FF_0000 | u32::from(*pair_index); + rows.push(row); + } + let rows_payload = research_words_payload( + &rows + .iter() + .flat_map(|row| row.iter().copied()) + .collect::<Vec<_>>(), + ); + + let mut name_pool = Vec::new(); + let mut pairs = Vec::with_capacity(spec.aliases.len()); + for (name, row_index) in spec.aliases { + let name_offset = u16::try_from(name_pool.len()).expect("test pool offset"); + name_pool.extend_from_slice(name); + name_pool.push(0); + pairs.push((name_offset, *row_index)); + } + let pairs_payload = research_pairs_payload(&pairs); + let (counts_23, ids_23) = research_relation_payload(spec.relations_23); + let (counts_45, ids_45) = research_relation_payload(spec.relations_45); + let with_tail = |mut payload: Vec<u8>| { + payload.extend_from_slice(RESEARCH_TEST_TAIL); + payload + }; + + let mut payloads = vec![ + ( + u32::from_le_bytes(*b"TRF0"), + with_tail(rows_payload), + spec.trf0_attr2, + ), + (u32::from_le_bytes(*b"TRF1"), spec.flags.to_vec(), 0), + (u32::from_le_bytes(*b"TRFB"), with_tail(pairs_payload), 0), + (u32::from_le_bytes(*b"TRF6"), name_pool, 0), + (u32::from_le_bytes(*b"TRF7"), Vec::new(), 0), + (u32::from_le_bytes(*b"TRF8"), Vec::new(), 0), + (u32::from_le_bytes(*b"TRF9"), Vec::new(), 0), + (u32::from_le_bytes(*b"TRFA"), Vec::new(), 0), + (u32::from_le_bytes(*b"TRF2"), with_tail(counts_23), 0), + (u32::from_le_bytes(*b"TRF4"), with_tail(counts_45), 0), + ]; + if spec.include_trf3 { + payloads.push((u32::from_le_bytes(*b"TRF3"), with_tail(ids_23), 0)); + } + if spec.include_trf5 { + payloads.push((u32::from_le_bytes(*b"TRF5"), with_tail(ids_45), 0)); + } + + let entries: Vec<_> = payloads + .iter() + .filter(|(type_id, _, _)| !spec.omitted_chunks.contains(type_id)) + .map(|(type_id, payload, attr2)| TestNresEntry { + name: b"ResTree", + payload, + type_id: *type_id, + attr2: *attr2, + }) + .collect(); + let mut archive = build_nres_with_meta(&entries); + for (type_id, count) in [ + (u32::from_le_bytes(*b"TRF0"), spec.costs.len()), + (u32::from_le_bytes(*b"TRF1"), spec.flags.len()), + (u32::from_le_bytes(*b"TRFB"), spec.aliases.len()), + (u32::from_le_bytes(*b"TRF2"), spec.relations_23.len()), + (u32::from_le_bytes(*b"TRF4"), spec.relations_45.len()), + ] { + if !spec.omitted_chunks.contains(&type_id) { + research_set_nres_attr1( + &mut archive, + type_id, + u32::try_from(count).expect("test count"), + ); + } + } + if spec.include_trf3 && !spec.omitted_chunks.contains(&u32::from_le_bytes(*b"TRF3")) { + let count: usize = spec.relations_23.iter().map(|group| group.len()).sum(); + research_set_nres_attr1( + &mut archive, + u32::from_le_bytes(*b"TRF3"), + u32::try_from(count).expect("test dependency count"), + ); + } + if spec.include_trf5 && !spec.omitted_chunks.contains(&u32::from_le_bytes(*b"TRF5")) { + let count: usize = spec.relations_45.iter().map(|group| group.len()).sum(); + research_set_nres_attr1( + &mut archive, + u32::from_le_bytes(*b"TRF5"), + u32::try_from(count).expect("test dependency count"), + ); + } + archive + } + + fn research_relation_payload(groups: &[&[u32]]) -> (Vec<u8>, Vec<u8>) { + let counts: Vec<_> = groups + .iter() + .map(|group| u32::try_from(group.len()).expect("test dependency group count")) + .collect(); + let ids: Vec<_> = groups + .iter() + .flat_map(|group| group.iter().copied()) + .collect(); + ( + research_words_payload(&counts), + research_words_payload(&ids), + ) + } + + fn research_set_payload_bytes(bytes: &mut [u8], type_id: u32, offset: usize, value: &[u8]) { + let entry_count = u32::from_le_bytes(bytes[8..12].try_into().expect("entry count")); + let total_size = u32::from_le_bytes(bytes[12..16].try_into().expect("total size")); + let directory_start = usize::try_from(total_size - entry_count * 64).expect("directory"); + for entry in bytes[directory_start..].chunks_exact(64) { + if u32::from_le_bytes(entry[..4].try_into().expect("type id")) != type_id { + continue; + } + let payload_start = usize::try_from(u32::from_le_bytes( + entry[56..60].try_into().expect("payload offset"), + )) + .expect("payload offset"); + let payload_len = usize::try_from(u32::from_le_bytes( + entry[12..16].try_into().expect("payload size"), + )) + .expect("payload size"); + let end = offset.checked_add(value.len()).expect("payload end"); + assert!(end <= payload_len, "test mutation stays within payload"); + bytes[payload_start + offset..payload_start + end].copy_from_slice(value); + return; + } + panic!("NRes entry type {type_id:#010x} was not built"); + } + + fn research_set_payload_byte(bytes: &mut [u8], type_id: u32, offset: usize, value: u8) { + research_set_payload_bytes(bytes, type_id, offset, &[value]); + } + + fn research_set_payload_u16(bytes: &mut [u8], type_id: u32, offset: usize, value: u16) { + research_set_payload_bytes(bytes, type_id, offset, &value.to_le_bytes()); + } + + fn research_set_payload_u32(bytes: &mut [u8], type_id: u32, offset: usize, value: u32) { + research_set_payload_bytes(bytes, type_id, offset, &value.to_le_bytes()); + } + + fn research_cycle_fixture(trf0_attr2: u32, flags: &[u8]) -> Vec<u8> { + let costs = [ + (1.0_f32, 10.0_f32), + (2.0_f32, 20.0_f32), + (3.0_f32, 30.0_f32), + (4.0_f32, 40.0_f32), + (5.0_f32, 50.0_f32), + ]; + let mut pool = Vec::new(); + let mut pairs = Vec::new(); + let mut rows = Vec::new(); + for (index, ((ore, power), name)) in costs + .iter() + .zip([b"A".as_slice(), b"B", b"C", b"D", b"E"]) + .enumerate() + { + let offset = u16::try_from(pool.len()).expect("pool offset"); + pool.extend_from_slice(name); + pool.push(0); + pairs.push((offset, u16::try_from(index).expect("row index"))); + rows.push([ + power.to_bits(), + ore.to_bits(), + 0, + 0, + 0, + 0, + 0, + 0, + 0x09FF_0000 | u32::try_from(index).expect("pair index"), + 0, + ]); + } + + let rows_payload = research_words_payload( + &rows + .iter() + .flat_map(|row| row.iter().copied()) + .collect::<Vec<_>>(), + ); + let pairs_payload = research_pairs_payload(&pairs); + let count_23 = [2_u32, 0, 0, 1, 1]; + let ids_23 = [3_u32, 1, 4, 0]; + let count_45 = [0_u32; 5]; + let count_23_payload = research_words_payload(&count_23); + let ids_23_payload = research_words_payload(&ids_23); + let count_45_payload = research_words_payload(&count_45); + let payloads: [(u32, &[u8], u32); 11] = [ + ( + u32::from_le_bytes(*b"TRF0"), + rows_payload.as_slice(), + trf0_attr2, + ), + (u32::from_le_bytes(*b"TRF1"), flags, 0), + (u32::from_le_bytes(*b"TRFB"), pairs_payload.as_slice(), 0), + (u32::from_le_bytes(*b"TRF6"), pool.as_slice(), 0), + (u32::from_le_bytes(*b"TRF7"), &[][..], 0), + (u32::from_le_bytes(*b"TRF8"), &[][..], 0), + (u32::from_le_bytes(*b"TRF9"), &[][..], 0), + (u32::from_le_bytes(*b"TRFA"), &[][..], 0), + (u32::from_le_bytes(*b"TRF2"), &count_23_payload, 0), + (u32::from_le_bytes(*b"TRF3"), &ids_23_payload, 0), + (u32::from_le_bytes(*b"TRF4"), &count_45_payload, 0), + ]; + let entries: Vec<_> = payloads + .iter() + .map(|(type_id, payload, attr2)| TestNresEntry { + name: b"ResTree", + payload, + type_id: *type_id, + attr2: *attr2, + }) + .collect(); + let mut archive = build_nres_with_meta(&entries); + for (type_id, count) in [ + (u32::from_le_bytes(*b"TRF0"), 5), + (u32::from_le_bytes(*b"TRF1"), 5), + (u32::from_le_bytes(*b"TRFB"), 5), + (u32::from_le_bytes(*b"TRF2"), 5), + (u32::from_le_bytes(*b"TRF3"), 3), + (u32::from_le_bytes(*b"TRF4"), 5), + ] { + research_set_nres_attr1(&mut archive, type_id, count); + } + archive + } + + fn research_words_payload(words: &[u32]) -> Vec<u8> { + words.iter().flat_map(|word| word.to_le_bytes()).collect() + } + + fn research_pairs_payload(pairs: &[(u16, u16)]) -> Vec<u8> { + pairs + .iter() + .flat_map(|(offset, index)| offset.to_le_bytes().into_iter().chain(index.to_le_bytes())) + .collect() + } + + fn research_set_nres_attr1(bytes: &mut [u8], type_id: u32, count: u32) { + let entry_count = u32::from_le_bytes(bytes[8..12].try_into().expect("entry count")); + let total_size = u32::from_le_bytes(bytes[12..16].try_into().expect("total size")); + let directory_start = usize::try_from(total_size - entry_count * 64).expect("directory"); + for entry in bytes[directory_start..].chunks_exact_mut(64) { + if u32::from_le_bytes(entry[..4].try_into().expect("type id")) == type_id { + entry[4..8].copy_from_slice(&count.to_le_bytes()); + return; + } + } + panic!("NRes entry type {type_id:#010x} was not built"); + } + + fn research_unit_dat_payload(keys: &[&[u8]], child_counts: &[u32], kinds: &[u32]) -> Vec<u8> { + assert_eq!(keys.len(), child_counts.len()); + assert_eq!(keys.len(), kinds.len()); + let mut bytes = vec![0; 8 + keys.len() * 112]; + bytes[..4].copy_from_slice(&0x0000_F0F1_u32.to_le_bytes()); + for (index, key) in keys.iter().enumerate() { + let offset = 8 + index * 112; + bytes[offset + 32..offset + 32 + key.len()].copy_from_slice(key); + bytes[offset + 64..offset + 68].copy_from_slice(&kinds[index].to_le_bytes()); + bytes[offset + 108..offset + 112].copy_from_slice(&child_counts[index].to_le_bytes()); + } + bytes + } + + fn research_unit_dat( + keys: &[&[u8]], + child_counts: &[u32], + kinds: &[u32], + ) -> fparkan_prototype::UnitDat { + fparkan_prototype::decode_unit_dat(&research_unit_dat_payload(keys, child_counts, kinds)) + .expect("candidate Unit DAT") + } + fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord { let mut fixed_words = [0; 44]; fixed_words[1] = key; @@ -5063,6 +6596,132 @@ mod tests { } #[test] + fn component_control_tag17_shares_common_cursor_and_preserves_raw_record_tails() { + 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, 3.0, 0.5, 0)]); + payload[12..16].copy_from_slice(&2_u32.to_le_bytes()); + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + + let tag17_start = payload.len(); + append_typed_record(&mut payload, 17, 2); + payload[tag17_start + 0x14..tag17_start + 0x18] + .copy_from_slice(&0xAABB_CCDDu32.to_le_bytes()); + payload[tag17_start + 0xAC..tag17_start + 0xB0].copy_from_slice(&2_u32.to_le_bytes()); + payload.splice( + tag17_start + 0xB0..tag17_start + 0xB0, + [0x1122_3344u32.to_le_bytes(), 0x5566_7788u32.to_le_bytes()].concat(), + ); + payload.extend_from_slice(b"fx\0"); + + let known_start = payload.len(); + append_typed_record(&mut payload, 4, 3); + payload[known_start + 0x28..known_start + 0x2C] + .copy_from_slice(&0xCAFE_D00Du32.to_le_bytes()); + payload[known_start + 0xAC..known_start + 0xB0].copy_from_slice(&1_u32.to_le_bytes()); + payload.splice( + known_start + 0xB0..known_start + 0xB0, + 0x0102_0304u32.to_le_bytes(), + ); + payload.extend_from_slice(b"ctl\0"); + + let group_header = payload.len(); + payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + append_effect_table( + &mut payload, + &[effect_row( + 4, + [4, 5, 6], + [12, 13, 14], + 15, + "effects.rlb", + "first", + )], + ); + + let control = decode_component_control_payload(&payload, source, 2, 0) + .expect("tag 17 and the following known record share the typed cursor"); + + assert_eq!( + control + .typed_records + .iter() + .map(|record| record.tag) + .collect::<Vec<_>>(), + [17, 4] + ); + assert_eq!(control.typed_records[0].fixed_words[5], 0xAABB_CCDD); + assert_eq!(control.typed_records[0].values, [0x1122_3344, 0x5566_7788]); + assert_eq!(control.typed_records[0].string_bytes, b"fx"); + assert_eq!( + control.typed_records[0].gate_provider, + PreparedControlGateProvider::NotApplicable + ); + assert_eq!(control.typed_records[1].fixed_words[10], 0xCAFE_D00D); + assert_eq!(control.typed_records[1].values, [0x0102_0304]); + assert_eq!(control.typed_records[1].string_bytes, b"ctl"); + assert_eq!(control.initial_effect_group, Some(0)); + assert_eq!( + control.effect_section, + PreparedControlEffectSection::Parsed(vec![PreparedControlEffectRow { + table_index: 0, + row_index: 0, + raw_condition: [4, 5, 6], + point_offsets: [12, 13, 14], + selector: 15, + resource: PreparedControlResourcePair { + archive: fixed_name("effects.rlb"), + name: fixed_name("first"), + }, + }]) + ); + } + + #[test] + fn component_control_truncated_tag17_frames_are_rejected() { + let source = ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(b"parts.ctl"), + type_id: None, + }; + let mut valid = component_control_payload(&[]); + valid[12..16].copy_from_slice(&1_u32.to_le_bytes()); + let record_start = valid.len(); + append_typed_record(&mut valid, 17, 2); + valid[record_start + 0xAC..record_start + 0xB0].copy_from_slice(&2_u32.to_le_bytes()); + valid.splice( + record_start + 0xB0..record_start + 0xB0, + [0x1234_5678u32.to_le_bytes(), 0x89AB_CDEFu32.to_le_bytes()].concat(), + ); + valid.extend_from_slice(b"xy\0"); + + let assert_invalid = |payload: Vec<u8>, label: &str| { + assert!( + matches!( + decode_component_control_payload(&payload, source.clone(), 0, 0), + Err(AssetError::InvalidPrototype(_)) + ), + "truncated recognized tag 17 {label} must be InvalidPrototype" + ); + }; + + for (end, label) in [ + (record_start + 0xAF, "prefix"), + (record_start + 0xB0 + 4, "values"), + (record_start + 0xB0 + 8, "length word"), + (record_start + 0xB0 + 8 + 4 + 2, "string bytes"), + ] { + assert_invalid(valid[..end].to_vec(), label); + } + let mut bad_nul = valid; + *bad_nul.last_mut().expect("string has trailing NUL") = b'!'; + assert_invalid(bad_nul, "trailing NUL"); + } + + #[test] fn component_control_malformed_recognized_typed_tail_is_rejected() { let source = ResourceKey { archive: parse_path("controls.rlb").expect("archive"), @@ -5206,6 +6865,138 @@ mod tests { } #[test] + fn initial_control_effect_opcodes_keep_raw_selected_rows_in_order() { + 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(&[]); + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + let group_header = payload.len(); + payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + let opcodes = [1, 4, 4, 12]; + append_effect_table( + &mut payload, + &opcodes.map(|opcode| effect_row(opcode, [0; 3], [0; 3], 0, "", "")), + ); + + let (control, decoded_opcodes) = + decode_component_control_payload_with_opcodes(&payload, source.clone(), 2, 0) + .expect("complete selected group"); + assert_eq!(decoded_opcodes, Some(opcodes.to_vec())); + let PreparedControlEffectSection::Parsed(rows) = control.effect_section else { + panic!("opcode-4 projection should remain parsed"); + }; + assert_eq!( + rows.iter().map(|row| row.row_index).collect::<Vec<_>>(), + [1, 2] + ); + + let repository = repository_with_archives(&[("controls.rlb", &[(b"parts.ctl", &payload)])]); + let manager = AssetManager::new(repository); + assert_eq!( + manager + .initial_control_effect_opcodes(&source, 2, 0) + .expect("public raw-opcode scanner"), + Some(opcodes.to_vec()) + ); + } + + #[test] + fn initial_control_effect_opcodes_distinguish_complete_empty_from_unknown_shapes() { + let source = |name: &[u8]| ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(name), + type_id: None, + }; + let empty = component_control_payload(&[]); + + let mut selected_noop = component_control_payload(&[]); + selected_noop[16..20].copy_from_slice(&1_u32.to_le_bytes()); + let noop_header = selected_noop.len(); + selected_noop.resize(noop_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + append_effect_table(&mut selected_noop, &[]); + + let mut unsupported = component_control_payload(&[]); + unsupported[12..16].copy_from_slice(&1_u32.to_le_bytes()); + append_typed_record(&mut unsupported, 99, 0); + + let mut trailing_data = component_control_payload(&[]); + trailing_data.push(0xAA); + let repository = repository_with_archives(&[( + "controls.rlb", + &[ + (b"empty.ctl", &empty), + (b"noop.ctl", &selected_noop), + (b"unsupported.ctl", &unsupported), + (b"trailing.ctl", &trailing_data), + ], + )]); + let manager = AssetManager::new(repository); + + assert_eq!( + manager + .initial_control_effect_opcodes(&source(b"empty.ctl"), 2, 0) + .expect("complete empty CTLD"), + Some(Vec::new()) + ); + assert_eq!( + manager + .initial_control_effect_opcodes(&source(b"noop.ctl"), 2, 0) + .expect("complete no-op selected group"), + Some(Vec::new()) + ); + for name in [b"unsupported.ctl".as_slice(), b"trailing.ctl".as_slice()] { + assert_eq!( + manager + .initial_control_effect_opcodes(&source(name), 2, 0) + .expect("unsupported but bounded shape"), + None, + "{} remains unavailable", + String::from_utf8_lossy(name) + ); + } + } + + #[test] + fn initial_control_effect_opcodes_treat_out_of_range_initial_indices_as_known_empty() { + let make_payload = |raw_initial_group: u32| { + let mut payload = component_control_payload(&[]); + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + let group_header = payload.len(); + payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + payload[group_header..group_header + 4] + .copy_from_slice(&raw_initial_group.to_le_bytes()); + append_effect_table(&mut payload, &[effect_row(1, [0; 3], [0; 3], 0, "", "")]); + payload + }; + let sentinel = make_payload(u32::MAX); + let exact_count = make_payload(1); + let repository = repository_with_archives(&[( + "controls.rlb", + &[ + (b"sentinel.ctl", &sentinel), + (b"exact-count.ctl", &exact_count), + ], + )]); + let manager = AssetManager::new(repository); + let sources = + [b"sentinel.ctl".as_slice(), b"exact-count.ctl".as_slice()].map(|name| ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(name), + type_id: None, + }); + let results = sources.map(|source| { + manager + .initial_control_effect_opcodes(&source, 2, 0) + .expect("complete CTLD frames") + }); + + assert_eq!(results, [Some(Vec::new()), Some(Vec::new())]); + } + + #[test] fn tag21_gate_provider_comes_from_validated_explicit_ndp() { let source = ResourceKey { archive: parse_path("parts.rlb").expect("CTL archive"), @@ -6678,6 +8469,90 @@ mod tests { } #[test] + fn mission_asset_plan_counts_texture_and_lightmap_requests() { + let msh = minimal_model_archive(); + let mat0 = mat0_with_texture(b"TEX_A"); + let texm = texm_payload(); + let repo = repository_with_archives_meta(&[ + ( + "static.rlb", + &[ + TestNresEntry { + name: b"tree.msh", + payload: &msh, + type_id: 0x4853_454D, + attr2: 0, + }, + TestNresEntry { + name: b"tree.wea", + payload: b"2\n0 MAT_A\n1 MAT_A\n\nLIGHTMAPS\n2\n0 LM_A\n1 LM_A\n", + type_id: WEAR_KIND, + attr2: 0, + }, + ], + ), + ( + "material.lib", + &[TestNresEntry { + name: b"MAT_A", + payload: &mat0, + type_id: MAT0_KIND, + attr2: 0, + }], + ), + ( + TEXTURES_ARCHIVE, + &[TestNresEntry { + name: b"TEX_A", + payload: &texm, + type_id: 0, + attr2: 0, + }], + ), + ( + LIGHTMAP_ARCHIVE, + &[TestNresEntry { + name: b"LM_A", + payload: &texm, + type_id: 0, + attr2: 0, + }], + ), + ]); + let mesh_key = ResourceKey { + archive: parse_path("static.rlb").expect("archive"), + name: resource_name(b"tree.msh"), + type_id: Some(0x4853_454D), + }; + let prototype = EffectivePrototype { + key: fparkan_prototype::PrototypeKey(resource_name(b"tree")), + geometry: PrototypeGeometry::Mesh(mesh_key.clone()), + source: fparkan_prototype::PrototypeSource::DirectArchive, + source_type: None, + dependencies: vec![mesh_key], + control_resource: None, + point_resource: None, + node_data_resource: None, + bas_resource: None, + }; + let (assets, report) = prepare_mission_assets_profiled_with_repository( + &repo, + &[0..1], + &[prototype], + AssetPreparationLimits::default(), + ) + .expect("prepared assets"); + let plan = assets.to_plan(); + + assert_eq!(assets.visuals.len(), 1); + assert_eq!(assets.visuals[0].texture_count, 2); + assert_eq!(assets.visuals[0].lightmap_count, 2); + assert_eq!(assets.textures.len(), 2); + assert_eq!(report.texture_count, 2); + assert_eq!((plan.texture_count, plan.lightmap_count), (2, 2)); + } + + #[test] fn asset_preparation_limits_reject_texture_pixel_budget() { let (repo, prototypes) = collision_fixture(); @@ -6772,6 +8647,165 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] + fn tag17_bareobject_controls_preserve_explicit_assets_and_real_effect_rows() { + let root = fixture_root("IS"); + let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root)); + let repository = CachedResourceRepository::new(Arc::clone(&vfs)); + let cases = [ + ( + "bm_m_01", + "bm_m_01.ctl", + "bm_m_a.cpt", + "bm_m_01.ndp", + "e45658e9f3a6b72519eb47bd03e3f41ebde12ab361361297ccb916d0aed5894d", + 0x3F66_6666, + 0x458C_A000, + ["engine_mis_m", "smoke_m_ms_01", "gunf_missile_m"], + ), + ( + "bm_l_01", + "bm_l_01.ctl", + "bm_l_a.cpt", + "bm_l_01.ndp", + "14fadfa27a4d4eab6ab9758f76a89031e085e880b42085219fa117562ab398cc", + 0x3F59_999A, + 0x457A_0000, + ["engine_mis_l", "smoke_l_ms_01", "gunf_missile_l"], + ), + ]; + + for ( + root_name, + control_name, + point_name, + ndp_name, + payload_hash, + word11_bits, + word13_bits, + names, + ) in cases + { + let prototypes = resolve_prototype_all( + &repository, + vfs.as_ref(), + &resource_name(root_name.as_bytes()), + ) + .expect("resolve the original bare-object registry entry"); + assert_eq!(prototypes.len(), 1, "{root_name}"); + let prototype = &prototypes[0]; + let control_source = prototype + .control_resource + .clone() + .expect("bare object has an explicit CTL reference"); + let point_source = prototype + .point_resource + .clone() + .expect("bare object has an explicit CPT reference"); + let ndp_source = prototype + .node_data_resource + .clone() + .expect("bare object has an explicit NDP reference"); + assert_eq!(control_source.archive.as_str(), "weapon.rlb"); + assert_eq!(control_source.name, resource_name(control_name.as_bytes())); + assert_eq!(point_source.archive.as_str(), "weapon.rlb"); + assert_eq!(point_source.name, resource_name(point_name.as_bytes())); + assert_eq!(ndp_source.archive.as_str(), "weapon.rlb"); + assert_eq!(ndp_source.name, resource_name(ndp_name.as_bytes())); + + let archive_id = repository + .open_archive(&control_source.archive) + .expect("open the referenced CTL archive"); + let entry = repository + .find(archive_id, &control_source.name) + .expect("find the referenced CTL") + .expect("referenced CTL exists"); + let payload = repository.read(entry).expect("read the referenced CTL"); + assert_eq!( + sha256_hex(&sha256(payload.as_slice())), + payload_hash, + "{root_name} CTL payload hash" + ); + + let assets = prepare_mission_assets_with_repository( + &repository, + &[0..prototypes.len()], + &prototypes, + ) + .expect("prepare the resolved object through the production asset path"); + assert_eq!(assets.object_component_controls.len(), 1, "{root_name}"); + assert_eq!(assets.object_component_controls[0].len(), 1, "{root_name}"); + let control = assets.object_component_controls[0][0] + .as_ref() + .expect("the resolved component has prepared control"); + assert_eq!(control.source, control_source, "{root_name} CTL source"); + let point_list = control + .point_list + .as_ref() + .expect("the parsed effect rows load the explicitly registered CPT"); + assert_eq!(point_list.source, point_source, "{root_name} CPT source"); + assert!( + point_list.points.len() >= 3, + "{root_name} effect point indices" + ); + let node_data = control + .node_data + .as_ref() + .expect("the explicit NDP remains attached"); + assert_eq!(node_data.source, ndp_source, "{root_name} NDP source"); + assert_eq!(node_data.rows.len(), 1, "{root_name} NDP rows"); + + assert_eq!(control.typed_records.len(), 1, "{root_name}"); + let record = &control.typed_records[0]; + assert_eq!(record.tag, 17, "{root_name}"); + let mut expected_fixed_words = [0_u32; 44]; + expected_fixed_words[0] = 17; + expected_fixed_words[3..=6].fill(u32::MAX); + expected_fixed_words[9] = 0x3F80_0000; + expected_fixed_words[10] = 0x3F80_0000; + expected_fixed_words[11] = word11_bits; + expected_fixed_words[12] = 0x43FA_0000; + expected_fixed_words[13] = word13_bits; + assert_eq!( + record.fixed_words, expected_fixed_words, + "{root_name} raw words" + ); + assert!(record.values.is_empty(), "{root_name} values"); + assert!(record.string_bytes.is_empty(), "{root_name} string"); + + let expected_rows = names + .iter() + .enumerate() + .map(|(row_index, name)| { + let mut raw_name = fixed_name(name); + match row_index { + 0 => raw_name[13..15].copy_from_slice(b"rd"), + 1 => raw_name[14..16].copy_from_slice(b"rd"), + _ => {} + } + PreparedControlEffectRow { + table_index: 0, + row_index, + raw_condition: [0; 3], + point_offsets: [0, 1, 2], + selector: u32::try_from(row_index + 1).expect("small effect selector"), + resource: PreparedControlResourcePair { + archive: fixed_name("effects.rlb"), + name: raw_name, + }, + } + }) + .collect::<Vec<_>>(); + assert_eq!(control.initial_effect_group, Some(0), "{root_name}"); + assert_eq!( + control.effect_section, + PreparedControlEffectSection::Parsed(expected_rows), + "{root_name} parsed type-4 rows" + ); + } + } + + #[test] + #[ignore = "requires licensed corpus"] fn prepares_real_unit_asset_plan() { let root = fixture_root("IS"); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root)); diff --git a/crates/fparkan-assets/src/research.rs b/crates/fparkan-assets/src/research.rs new file mode 100644 index 0000000..b261928 --- /dev/null +++ b/crates/fparkan-assets/src/research.rs @@ -0,0 +1,1151 @@ +use crate::{decode_nres_payload, NresDocument}; +use fparkan_binary::{checked_allocation_len, checked_count_bytes, Cursor, DecodeError, Limits}; +use fparkan_nres::{EntryId, NresEntry}; +use fparkan_path::NormalizedPath; +use fparkan_prototype::{ + decode_unit_dat, unit_component_tree, PrototypeError, UnitComponentRecord, + UnitComponentTreeNode, UnitDat, +}; +use fparkan_vfs::Vfs; +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; + +const TRF0: u32 = u32::from_le_bytes(*b"TRF0"); +const TRF1: u32 = u32::from_le_bytes(*b"TRF1"); +const TRF2: u32 = u32::from_le_bytes(*b"TRF2"); +const TRF3: u32 = u32::from_le_bytes(*b"TRF3"); +const TRF4: u32 = u32::from_le_bytes(*b"TRF4"); +const TRF5: u32 = u32::from_le_bytes(*b"TRF5"); +const TRF6: u32 = u32::from_le_bytes(*b"TRF6"); +const TRF7: u32 = u32::from_le_bytes(*b"TRF7"); +const TRF8: u32 = u32::from_le_bytes(*b"TRF8"); +const TRF9: u32 = u32::from_le_bytes(*b"TRF9"); +const TRFA: u32 = u32::from_le_bytes(*b"TRFA"); +const TRFB: u32 = u32::from_le_bytes(*b"TRFB"); +const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1; +const UNIT_DAT_HEADER_BYTES: usize = 8; +const UNIT_DAT_RECORD_BYTES: usize = 112; +const INITIAL_RESEARCH_COST_BOUND: f32 = f32::from_bits(0x4b18_967f); + +const RESEARCH_CHUNKS: [u32; 12] = [ + TRF0, TRF1, TRF2, TRF3, TRF4, TRF5, TRF6, TRF7, TRF8, TRF9, TRFA, TRFB, +]; + +/// Ore and power outputs from one native CalcFullResearchCost call. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResearchCost { + /// First native output, labeled ore by the Calc debug string. + pub ore: f32, + /// Second native output, labeled power by the Calc debug string. + pub power: f32, +} + +/// One decoded MisLoad research table and its original NRes archive. +#[derive(Debug)] +pub struct ResearchTrfDocument { + archive: NresDocument, + records: Vec<[u32; 10]>, + flags: Vec<u8>, + aliases: Vec<ResearchAlias>, + name_pool: Vec<u8>, + relation_2_3: ResearchRelations, + relation_4_5: ResearchRelations, +} + +/// One ordered research candidate and its native per-entry status byte. +#[derive(Clone, Debug)] +pub struct ResearchCandidate { + /// Unit DAT path read through the caller's VFS. + pub path: NormalizedPath, + /// Native status: zero entries are considered by the next refresh. + pub status: u8, +} + +/// Mutable candidate catalog and selected-index state for the research chooser. +#[derive(Clone, Debug, Default)] +pub struct ResearchCandidateSet { + /// Candidate entries in native supplied order. + pub entries: Vec<ResearchCandidate>, + /// Selected candidate index, preserved when no lower-cost winner exists. + pub selected_index: u32, + /// Whether the candidate catalog has been loaded. + pub loaded: bool, + /// Native candidate-set scan status byte. + pub status: u8, +} + +impl ResearchCandidateSet { + /// Refreshes this set's native selector state from the supplied research table. + /// + /// # Errors + /// + /// Returns explicit errors for unsupported lookup semantics or malformed + /// research-table fields reached by the predicates. + /// Missing paths, invalid candidate DAT framing and an empty-table cost + /// failure skip only that entry. + pub fn refresh_selection( + &mut self, + vfs: &dyn Vfs, + research: Option<&ResearchTrfDocument>, + ) -> Result<(), ResearchError> { + let Some(research) = research else { + return Ok(()); + }; + if !self.loaded { + return Ok(()); + } + + self.status = 1; + let mut running_bound = INITIAL_RESEARCH_COST_BOUND; + for index in 0..self.entries.len() { + if self.entries[index].status != 0 { + continue; + } + + self.status = 0; + let Ok(bytes) = vfs.read(&self.entries[index].path) else { + continue; + }; + let Ok((candidate, tree)) = decode_candidate_first_tree(&bytes) else { + continue; + }; + + let first = first_research_predicate(&candidate, &tree, research)?; + self.entries[index].status = u8::from(first); + if !first { + continue; + } + + let second = second_research_predicate(&candidate, &tree, research)?; + self.entries[index].status = u8::from(second); + let cost = match calc_full_research_cost(&candidate, research) { + Ok(cost) => cost, + Err(ResearchError::EmptyResearchTable) => continue, + Err(error) => return Err(error), + }; + if !second && cost.ore < running_bound { + let selected_index = u32::try_from(index) + .map_err(|_| ResearchError::Binary(DecodeError::IntegerOverflow))?; + running_bound = cost.ore; + self.selected_index = selected_index; + } + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +struct ResearchAlias { + name_range: std::ops::Range<usize>, + row_index: u16, +} + +#[derive(Debug)] +struct ResearchRelations { + table_id: u32, + group_starts: Vec<usize>, + indices: Vec<u32>, +} + +#[derive(Debug)] +struct Chunk<'a> { + entry: &'a NresEntry, + payload: &'a [u8], +} + +/// TRF decoding and cost-calculation error. +#[derive(Debug)] +pub enum ResearchError { + /// The outer NRes archive is malformed. + Nres(fparkan_nres::NresError), + /// A bounded TRF field is truncated or overflows. + Binary(DecodeError), + /// A native-required TRF chunk is absent. + MissingChunk { type_id: u32 }, + /// A recognized TRF chunk occurs more than once. + DuplicateChunk { type_id: u32 }, + /// The native loader requires TRF0 attribute 2 to equal three. + InvalidTrf0Attribute { got: u32 }, + /// A TRF1 payload is too large for the native zero-initialized flag buffer. + FlagsTooLong { row_count: usize, actual: usize }, + /// A TRFB pair points outside the TRF6 name pool. + InvalidNameOffset { pair_index: usize, offset: u16 }, + /// A TRF6 string does not end in NUL before the pool ends. + UnterminatedName { pair_index: usize, offset: u16 }, + /// A TRF0 row's low 16-bit pair link is outside TRFB. + InvalidRecordPair { + record_index: usize, + pair_index: u16, + pair_count: usize, + }, + /// A key or relation resolves to a research row outside TRF0. + InvalidResearchIndex { + type_id: u32, + index: u32, + row_count: usize, + }, + /// A relation table has no group at this research row. + MissingRelationGroup { + type_id: u32, + index: usize, + group_count: usize, + }, + /// A required non-empty relation stream is absent. + MissingRelationStream { type_id: u32, required_ids: u64 }, + /// A candidate Unit DAT resource field is not NUL-terminated. + MissingCandidateKeyTerminator { component_index: usize }, + /// No TRFB key matches an enabled candidate component. + UnknownResearchKey { + component_index: usize, + key: Vec<u8>, + }, + /// A non-ASCII lookup would require an unverified locale-specific fold. + UnsupportedNonAsciiLookup { + query: Vec<u8>, + alias: Option<Vec<u8>>, + }, + /// The research table has no rows. + EmptyResearchTable, + /// The candidate Unit DAT has no root component. + EmptyCandidateTree, + /// The existing Unit DAT tree builder rejected the child-count framing. + CandidateTree(PrototypeError), +} + +impl fmt::Display for ResearchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nres(source) => write!(formatter, "{source}"), + Self::Binary(source) => write!(formatter, "{source}"), + Self::MissingChunk { type_id } => { + write!(formatter, "research NRes is missing chunk {type_id:#010x}") + } + Self::DuplicateChunk { type_id } => { + write!(formatter, "research NRes has duplicate chunk {type_id:#010x}") + } + Self::InvalidTrf0Attribute { got } => { + write!(formatter, "TRF0 attribute 2 is {got}, expected 3") + } + Self::FlagsTooLong { row_count, actual } => write!( + formatter, + "TRF1 has {actual} flags, exceeding the {row_count}-byte native buffer" + ), + Self::InvalidNameOffset { pair_index, offset } => write!( + formatter, + "TRFB pair {pair_index} name offset {offset} is outside TRF6" + ), + Self::UnterminatedName { pair_index, offset } => write!( + formatter, + "TRFB pair {pair_index} name at TRF6 offset {offset} is not NUL-terminated" + ), + Self::InvalidRecordPair { + record_index, + pair_index, + pair_count, + } => write!( + formatter, + "TRF0 row {record_index} links to TRFB pair {pair_index}, but there are {pair_count} pairs" + ), + Self::InvalidResearchIndex { + type_id, + index, + row_count, + } => write!( + formatter, + "research index {index} from {type_id:#010x} exceeds TRF0 row count {row_count}" + ), + Self::MissingRelationGroup { + type_id, + index, + group_count, + } => write!( + formatter, + "{type_id:#010x} has no relation group for row {index} (group count {group_count})" + ), + Self::MissingRelationStream { + type_id, + required_ids, + } => write!( + formatter, + "{type_id:#010x} is absent but its count table requires {required_ids} indices" + ), + Self::MissingCandidateKeyTerminator { component_index } => write!( + formatter, + "candidate component {component_index} has no NUL in its 32-byte resource key" + ), + Self::UnknownResearchKey { + component_index, + key, + } => write!( + formatter, + "candidate component {component_index} key {:?} is absent from TRFB", + String::from_utf8_lossy(key) + ), + Self::UnsupportedNonAsciiLookup { query, alias } => { + if let Some(alias) = alias { + write!( + formatter, + "research key comparison between {:?} and {:?} needs unverified non-ASCII case folding", + String::from_utf8_lossy(query), + String::from_utf8_lossy(alias) + ) + } else { + write!( + formatter, + "research key {:?} needs unverified non-ASCII case folding", + String::from_utf8_lossy(query) + ) + } + } + Self::EmptyResearchTable => write!(formatter, "research table has no rows"), + Self::EmptyCandidateTree => write!(formatter, "candidate Unit DAT has no root component"), + Self::CandidateTree(source) => write!(formatter, "{source}"), + } + } +} + +impl std::error::Error for ResearchError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Nres(source) => Some(source), + Self::Binary(source) => Some(source), + Self::CandidateTree(source) => Some(source), + Self::MissingChunk { .. } + | Self::DuplicateChunk { .. } + | Self::InvalidTrf0Attribute { .. } + | Self::FlagsTooLong { .. } + | Self::InvalidNameOffset { .. } + | Self::UnterminatedName { .. } + | Self::InvalidRecordPair { .. } + | Self::InvalidResearchIndex { .. } + | Self::MissingRelationGroup { .. } + | Self::MissingRelationStream { .. } + | Self::MissingCandidateKeyTerminator { .. } + | Self::UnknownResearchKey { .. } + | Self::UnsupportedNonAsciiLookup { .. } + | Self::EmptyResearchTable + | Self::EmptyCandidateTree => None, + } + } +} + +impl From<fparkan_nres::NresError> for ResearchError { + fn from(value: fparkan_nres::NresError) -> Self { + Self::Nres(value) + } +} + +impl From<DecodeError> for ResearchError { + fn from(value: DecodeError) -> Self { + Self::Binary(value) + } +} + +impl From<PrototypeError> for ResearchError { + fn from(value: PrototypeError) -> Self { + Self::CandidateTree(value) + } +} + +impl ResearchTrfDocument { + /// Decodes the native research table chunks from an NRes archive. + /// + /// NRes validates the archive framing and payload ranges. This reader then + /// validates only the TRF bytes needed by its row, key, and relation views. + /// + /// # Errors + /// + /// Returns an error for malformed NRes framing, a missing required chunk, + /// an invalid TRF0 guard, or a referenced byte range outside its chunk. + pub fn decode(bytes: Arc<[u8]>) -> Result<Self, ResearchError> { + let archive = decode_nres_payload(bytes)?; + let mut chunk_ids = HashMap::new(); + for entry in archive.entries() { + let type_id = entry.meta().type_id; + if RESEARCH_CHUNKS.contains(&type_id) && chunk_ids.insert(type_id, entry.id()).is_some() + { + return Err(ResearchError::DuplicateChunk { type_id }); + } + } + + let trf0 = required_chunk(&archive, &chunk_ids, TRF0)?; + if trf0.entry.meta().attr2 != 3 { + return Err(ResearchError::InvalidTrf0Attribute { + got: trf0.entry.meta().attr2, + }); + } + let row_count = trf0.entry.meta().attr1; + let row_bytes = counted_prefix(trf0.payload, u64::from(row_count), 40)?; + let records = read_research_rows(row_bytes, row_count)?; + + let trf1 = required_chunk(&archive, &chunk_ids, TRF1)?; + let flag_count = usize::try_from(row_count).map_err(|_| DecodeError::IntegerOverflow)?; + if trf1.payload.len() > flag_count { + return Err(ResearchError::FlagsTooLong { + row_count: flag_count, + actual: trf1.payload.len(), + }); + } + let mut flags = vec![0; flag_count]; + flags[..trf1.payload.len()].copy_from_slice(trf1.payload); + + let trfb = required_chunk(&archive, &chunk_ids, TRFB)?; + let alias_count = trfb.entry.meta().attr1; + let pair_bytes = counted_prefix(trfb.payload, u64::from(alias_count), 4)?; + let trf6 = required_chunk(&archive, &chunk_ids, TRF6)?; + let name_pool = trf6.payload.to_vec(); + let aliases = read_aliases(pair_bytes, &name_pool, alias_count)?; + + for (record_index, record) in records.iter().enumerate() { + // The native reader casts the packed word to u16 before indexing TRFB. + let pair_index = record[8] as u16; + if usize::from(pair_index) >= aliases.len() { + return Err(ResearchError::InvalidRecordPair { + record_index, + pair_index, + pair_count: aliases.len(), + }); + } + } + + for type_id in [TRF7, TRF8, TRF9, TRFA] { + required_chunk(&archive, &chunk_ids, type_id)?; + } + + let relation_2_3 = read_relations(&archive, &chunk_ids, TRF2, TRF3)?; + let relation_4_5 = read_relations(&archive, &chunk_ids, TRF4, TRF5)?; + + Ok(Self { + archive, + records, + flags, + aliases, + name_pool, + relation_2_3, + relation_4_5, + }) + } + + /// Returns the retained NRes document, including opaque chunks and unused payload tails. + #[must_use] + pub fn archive(&self) -> &NresDocument { + &self.archive + } + + /// Number of TRF0 rows, taken from that chunk's own attr1. + #[must_use] + pub fn row_count(&self) -> usize { + self.records.len() + } + + /// Looks up the first ASCII-case-insensitive TRFB alias in source order. + /// + /// The shipped research keys are ASCII and use ASCII case-insensitive matching. + /// Comparisons involving non-ASCII bytes fail explicitly because native + /// locale-specific folding for those bytes is unknown. + /// + /// # Errors + /// + /// Returns an error if matching requires unverified non-ASCII folding or + /// the selected alias contains an out-of-range row id. + pub fn find_key(&self, key: &[u8]) -> Result<Option<usize>, ResearchError> { + let key = key.split(|byte| *byte == 0).next().unwrap_or_default(); + if !key.is_ascii() { + return Err(ResearchError::UnsupportedNonAsciiLookup { + query: key.to_vec(), + alias: None, + }); + } + for alias in &self.aliases { + let name = &self.name_pool[alias.name_range.clone()]; + if !name.is_ascii() { + return Err(ResearchError::UnsupportedNonAsciiLookup { + query: key.to_vec(), + alias: Some(name.to_vec()), + }); + } + if name.eq_ignore_ascii_case(key) { + let row_index = u32::from(alias.row_index); + if usize::from(alias.row_index) >= self.records.len() { + return Err(ResearchError::InvalidResearchIndex { + type_id: TRFB, + index: row_index, + row_count: self.records.len(), + }); + } + return Ok(Some(usize::from(alias.row_index))); + } + } + Ok(None) + } + + /// Returns the preserved ten raw TRF0 words for one row. + #[must_use] + pub fn row_words(&self, index: usize) -> Option<&[u32; 10]> { + self.records.get(index) + } + + /// Returns the original TRF1 byte for one row. + #[must_use] + pub fn flag_byte(&self, index: usize) -> Option<u8> { + self.flags.get(index).copied() + } + + /// Returns the raw name bytes selected by a row's low-16-bit TRFB link. + /// + /// # Errors + /// + /// Returns an error when the row is outside TRF0. + pub fn row_name(&self, index: usize) -> Result<&[u8], ResearchError> { + let row = self + .records + .get(index) + .ok_or(ResearchError::InvalidResearchIndex { + type_id: TRF0, + index: u32::try_from(index).unwrap_or(u32::MAX), + row_count: self.records.len(), + })?; + let pair_index = usize::from(row[8] as u16); + let alias = self + .aliases + .get(pair_index) + .ok_or(ResearchError::InvalidRecordPair { + record_index: index, + pair_index: row[8] as u16, + pair_count: self.aliases.len(), + })?; + Ok(&self.name_pool[alias.name_range.clone()]) + } + + /// Returns the ordered TRF2/TRF3 indices used by CalcFullResearchCost. + /// + /// # Errors + /// + /// Returns an error if this row has no TRF2 group or its TRF3 stream + /// contains a row index outside TRF0. + pub fn relation_2_3(&self, index: usize) -> Result<&[u32], ResearchError> { + let related = self.relation_2_3.get(index)?; + validate_relation_indices(related, TRF3, self.records.len())?; + Ok(related) + } + + /// Returns the separate ordered TRF4/TRF5 index group. + /// + /// # Errors + /// + /// Returns an error if this row has no TRF4 group or its TRF5 stream + /// contains a row index outside TRF0. + pub fn relation_4_5(&self, index: usize) -> Result<&[u32], ResearchError> { + let related = self.relation_4_5.get(index)?; + validate_relation_indices(related, TRF5, self.records.len())?; + Ok(related) + } + + fn gates(&self, index: usize) -> Result<(u32, u32, u32), ResearchError> { + let flag = self + .flags + .get(index) + .copied() + .ok_or(ResearchError::InvalidResearchIndex { + type_id: TRF1, + index: u32::try_from(index).unwrap_or(u32::MAX), + row_count: self.records.len(), + })?; + Ok(( + u32::from((flag >> 1) & 1), + u32::from(flag & 1), + u32::from((flag >> 2) & 1), + )) + } +} + +pub(super) fn decode_candidate_first_tree( + payload: &[u8], +) -> Result<(UnitDat, Vec<UnitComponentTreeNode>), 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()); + } + if payload.len() < UNIT_DAT_HEADER_BYTES { + return Err(PrototypeError::InvalidSize); + } + let magic = u32::from_le_bytes( + payload[..4] + .try_into() + .map_err(|_| PrototypeError::InvalidSize)?, + ); + if magic != UNIT_DAT_MAGIC { + return Err(PrototypeError::InvalidUnitDatMagic(magic)); + } + + let available_body = payload.len() - UNIT_DAT_HEADER_BYTES; + let complete_records = available_body / UNIT_DAT_RECORD_BYTES; + if complete_records == 0 { + return Err(PrototypeError::InvalidSize); + } + let maximum_records = + usize::try_from(limits.max_entries).map_err(|_| DecodeError::IntegerOverflow)?; + let scan_records = complete_records.min(maximum_records); + let mut pending_nodes = 1_u64; + let mut first_tree_count = None; + + for record_index in 0..scan_records { + let record_offset = record_index + .checked_mul(UNIT_DAT_RECORD_BYTES) + .and_then(|offset| UNIT_DAT_HEADER_BYTES.checked_add(offset)) + .ok_or(DecodeError::IntegerOverflow)?; + let children_offset = record_offset + .checked_add(108) + .ok_or(DecodeError::IntegerOverflow)?; + let children_end = children_offset + .checked_add(4) + .ok_or(DecodeError::IntegerOverflow)?; + let child_count = u32::from_le_bytes( + payload + .get(children_offset..children_end) + .ok_or(PrototypeError::InvalidSize)? + .try_into() + .map_err(|_| PrototypeError::InvalidSize)?, + ); + pending_nodes = pending_nodes + .checked_sub(1) + .and_then(|pending| pending.checked_add(u64::from(child_count))) + .ok_or(DecodeError::IntegerOverflow)?; + if pending_nodes > u64::from(limits.max_entries) { + return Err(DecodeError::LimitExceeded { + count: pending_nodes, + limit: u64::from(limits.max_entries), + } + .into()); + } + if pending_nodes == 0 { + first_tree_count = Some( + record_index + .checked_add(1) + .ok_or(DecodeError::IntegerOverflow)?, + ); + break; + } + } + + let Some(record_count) = first_tree_count else { + if scan_records == maximum_records { + return Err(DecodeError::LimitExceeded { + count: u64::from(limits.max_entries) + 1, + limit: u64::from(limits.max_entries), + } + .into()); + } + return Err(PrototypeError::InvalidSize); + }; + let record_count = u64::try_from(record_count).map_err(|_| DecodeError::IntegerOverflow)?; + let _record_capacity = checked_allocation_len(record_count, u64::from(limits.max_entries))?; + let record_bytes = checked_count_bytes( + record_count, + UNIT_DAT_RECORD_BYTES as u64, + u64::try_from(available_body).map_err(|_| DecodeError::IntegerOverflow)?, + )?; + let record_size = u64::try_from(std::mem::size_of::<UnitComponentRecord>()) + .map_err(|_| DecodeError::IntegerOverflow)?; + let tree_record_size = std::mem::size_of::<UnitComponentTreeNode>() + .checked_add(2 * std::mem::size_of::<usize>()) + .ok_or(DecodeError::IntegerOverflow)?; + let tree_record_size = + u64::try_from(tree_record_size).map_err(|_| DecodeError::IntegerOverflow)?; + let decoded_bytes = record_size + .checked_add(tree_record_size) + .and_then(|size| size.checked_mul(record_count)) + .ok_or(DecodeError::IntegerOverflow)?; + let _decoded_capacity = checked_allocation_len(decoded_bytes, limits.max_decoded_bytes)?; + let prefix_end = UNIT_DAT_HEADER_BYTES + .checked_add(record_bytes) + .ok_or(DecodeError::IntegerOverflow)?; + + let candidate = decode_unit_dat(&payload[..prefix_end])?; + let tree = unit_component_tree(&candidate.records)?; + Ok((candidate, tree)) +} + +fn candidate_research_predicate( + candidate: &UnitDat, + tree: &[UnitComponentTreeNode], + research: &ResearchTrfDocument, + first_info_word: usize, + second_info_word: usize, +) -> Result<bool, ResearchError> { + let mut stack = vec![0_usize]; + while let Some(node_index) = stack.pop() { + let Some(node) = tree.get(node_index) else { + return Err(ResearchError::CandidateTree( + PrototypeError::InvalidUnitHierarchy(format!( + "component index {node_index} is outside the reconstructed tree" + )), + )); + }; + let Some(component) = candidate.records.get(node.record_index) else { + return Err(ResearchError::CandidateTree( + PrototypeError::InvalidUnitHierarchy(format!( + "tree references missing component {}", + node.record_index + )), + )); + }; + if component.kind == 0 { + continue; + } + + let Some(key_end) = component.resource_raw.iter().position(|byte| *byte == 0) else { + return Err(ResearchError::MissingCandidateKeyTerminator { + component_index: node.record_index, + }); + }; + let Some(research_index) = research.find_key(&component.resource_raw[..key_end])? else { + return Ok(false); + }; + let (word0, word1, word2) = research.gates(research_index)?; + let info_words = [word0, word1, word2]; + if info_words[first_info_word] == 0 || info_words[second_info_word] == 0 { + return Ok(false); + } + for child in node.children.iter().rev() { + stack.push(*child); + } + } + Ok(true) +} + +fn first_research_predicate( + candidate: &UnitDat, + tree: &[UnitComponentTreeNode], + research: &ResearchTrfDocument, +) -> Result<bool, ResearchError> { + candidate_research_predicate(candidate, tree, research, 1, 2) +} + +fn second_research_predicate( + candidate: &UnitDat, + tree: &[UnitComponentTreeNode], + research: &ResearchTrfDocument, +) -> Result<bool, ResearchError> { + candidate_research_predicate(candidate, tree, research, 0, 2) +} + +/// Calculates the ordered ore and power cost for one candidate Unit DAT tree. +/// +/// # Errors +/// +/// Returns an error for an empty research table, malformed candidate tree, +/// missing or unsupported key comparison, invalid relation group, or invalid +/// row reference. +pub fn calc_full_research_cost( + candidate: &UnitDat, + research: &ResearchTrfDocument, +) -> Result<ResearchCost, ResearchError> { + if research.records.is_empty() { + return Err(ResearchError::EmptyResearchTable); + } + let tree = unit_component_tree(&candidate.records)?; + if tree.is_empty() { + return Err(ResearchError::EmptyCandidateTree); + } + + let mut visited = vec![false; research.records.len()]; + let direct = collect_direct_indices(candidate, &tree, research, &mut visited)?; + let mut closure = Vec::new(); + for &index in &direct { + closure.push(index); + let (_, recurse, _) = research.gates(index)?; + if recurse == 0 { + append_relation_closure(index, research, &mut visited, &mut closure)?; + } + } + + let mut ore = 0.0_f32; + let mut power = 0.0_f32; + for index in closure { + let (included, _, _) = research.gates(index)?; + if included == 0 { + let row = research + .records + .get(index) + .ok_or(ResearchError::InvalidResearchIndex { + type_id: TRF0, + index: u32::try_from(index).unwrap_or(u32::MAX), + row_count: research.records.len(), + })?; + ore = ore + f32::from_bits(row[1]); + power = power + f32::from_bits(row[0]); + } + } + + Ok(ResearchCost { ore, power }) +} + +fn collect_direct_indices( + candidate: &UnitDat, + tree: &[UnitComponentTreeNode], + research: &ResearchTrfDocument, + visited: &mut [bool], +) -> Result<Vec<usize>, ResearchError> { + let mut direct = Vec::new(); + let mut stack = vec![0_usize]; + let mut first_error = None; + + while let Some(node_index) = stack.pop() { + let Some(node) = tree.get(node_index) else { + return Err(ResearchError::CandidateTree( + PrototypeError::InvalidUnitHierarchy(format!( + "component index {node_index} is outside the reconstructed tree" + )), + )); + }; + let Some(component) = candidate.records.get(node.record_index) else { + return Err(ResearchError::CandidateTree( + PrototypeError::InvalidUnitHierarchy(format!( + "tree references missing component {}", + node.record_index + )), + )); + }; + + if component.kind == 0 { + continue; + } + + let key_end = component.resource_raw.iter().position(|byte| *byte == 0); + let key = match key_end { + Some(end) => &component.resource_raw[..end], + None => { + if first_error.is_none() { + first_error = Some(ResearchError::MissingCandidateKeyTerminator { + component_index: node.record_index, + }); + } + for child in node.children.iter().rev() { + stack.push(*child); + } + continue; + } + }; + + match research.find_key(key) { + Ok(Some(index)) => match research.gates(index) { + Ok((included, _, _)) => { + if included == 0 && !visited[index] { + visited[index] = true; + direct.push(index); + } + } + Err(error) => { + if first_error.is_none() { + first_error = Some(error); + } + } + }, + Ok(None) => { + if first_error.is_none() { + first_error = Some(ResearchError::UnknownResearchKey { + component_index: node.record_index, + key: key.to_vec(), + }); + } + } + Err(error) => { + if first_error.is_none() { + first_error = Some(error); + } + } + } + + for child in node.children.iter().rev() { + stack.push(*child); + } + } + + if let Some(error) = first_error { + return Err(error); + } + Ok(direct) +} + +#[derive(Clone, Copy)] +struct RelationFrame { + row_index: usize, + next_child: usize, +} + +fn append_relation_closure( + root: usize, + research: &ResearchTrfDocument, + visited: &mut [bool], + closure: &mut Vec<usize>, +) -> Result<(), ResearchError> { + let mut stack = vec![RelationFrame { + row_index: root, + next_child: 0, + }]; + + while let Some(frame) = stack.last().copied() { + let related = research.relation_2_3.get(frame.row_index)?; + if frame.next_child >= related.len() { + stack.pop(); + continue; + } + let related_index = related[frame.next_child]; + stack + .last_mut() + .expect("non-empty relation stack") + .next_child += 1; + + let index = usize::try_from(related_index).map_err(|_| DecodeError::IntegerOverflow)?; + if index >= research.records.len() { + return Err(ResearchError::InvalidResearchIndex { + type_id: TRF3, + index: related_index, + row_count: research.records.len(), + }); + } + let (included, recurse, _) = research.gates(index)?; + if included != 0 || visited[index] { + continue; + } + + visited[index] = true; + closure.push(index); + if recurse == 0 { + stack.push(RelationFrame { + row_index: index, + next_child: 0, + }); + } + } + + Ok(()) +} + +impl ResearchRelations { + fn get(&self, index: usize) -> Result<&[u32], ResearchError> { + let group_count = self.group_starts.len().saturating_sub(1); + let missing_group = || ResearchError::MissingRelationGroup { + type_id: self.table_id, + index, + group_count, + }; + let Some(end_index) = index.checked_add(1) else { + return Err(missing_group()); + }; + let Some(&start) = self.group_starts.get(index) else { + return Err(missing_group()); + }; + let Some(&end) = self.group_starts.get(end_index) else { + return Err(missing_group()); + }; + Ok(&self.indices[start..end]) + } +} + +fn validate_relation_indices( + related: &[u32], + type_id: u32, + row_count: usize, +) -> Result<(), ResearchError> { + for &index in related { + if usize::try_from(index).map_or(true, |value| value >= row_count) { + return Err(ResearchError::InvalidResearchIndex { + type_id, + index, + row_count, + }); + } + } + Ok(()) +} + +fn required_chunk<'a>( + archive: &'a NresDocument, + chunk_ids: &HashMap<u32, EntryId>, + type_id: u32, +) -> Result<Chunk<'a>, ResearchError> { + let id = chunk_ids + .get(&type_id) + .copied() + .ok_or(ResearchError::MissingChunk { type_id })?; + let entry = archive + .entry(id) + .ok_or(fparkan_nres::NresError::EntryIdOutOfRange { + id: id.0, + entry_count: archive.header().entry_count, + })?; + let payload = archive.payload(id)?; + Ok(Chunk { entry, payload }) +} + +fn optional_chunk<'a>( + archive: &'a NresDocument, + chunk_ids: &HashMap<u32, EntryId>, + type_id: u32, +) -> Result<Option<Chunk<'a>>, ResearchError> { + let Some(id) = chunk_ids.get(&type_id).copied() else { + return Ok(None); + }; + let entry = archive + .entry(id) + .ok_or(fparkan_nres::NresError::EntryIdOutOfRange { + id: id.0, + entry_count: archive.header().entry_count, + })?; + let payload = archive.payload(id)?; + Ok(Some(Chunk { entry, payload })) +} + +fn counted_prefix(payload: &[u8], count: u64, stride: u64) -> Result<&[u8], ResearchError> { + let remaining = u64::try_from(payload.len()).map_err(|_| DecodeError::IntegerOverflow)?; + let size = checked_count_bytes(count, stride, remaining)?; + Ok(&payload[..size]) +} + +fn read_research_rows(bytes: &[u8], row_count: u32) -> Result<Vec<[u32; 10]>, ResearchError> { + let capacity = checked_allocation_len( + u64::from(row_count), + u64::from(Limits::default().max_array_items), + )?; + let mut cursor = Cursor::new(bytes); + let mut records = Vec::with_capacity(capacity); + for _ in 0..row_count { + let mut row = [0_u32; 10]; + for word in &mut row { + *word = cursor.read_u32_le()?; + } + records.push(row); + } + cursor.require_eof()?; + Ok(records) +} + +fn read_aliases( + bytes: &[u8], + name_pool: &[u8], + alias_count: u32, +) -> Result<Vec<ResearchAlias>, ResearchError> { + let capacity = checked_allocation_len( + u64::from(alias_count), + u64::from(Limits::default().max_array_items), + )?; + let mut cursor = Cursor::new(bytes); + let mut aliases = Vec::with_capacity(capacity); + for pair_index in 0..capacity { + let name_offset = cursor.read_u16_le()?; + let row_index = cursor.read_u16_le()?; + let offset = usize::from(name_offset); + let suffix = name_pool + .get(offset..) + .ok_or(ResearchError::InvalidNameOffset { + pair_index, + offset: name_offset, + })?; + let length = + suffix + .iter() + .position(|byte| *byte == 0) + .ok_or(ResearchError::UnterminatedName { + pair_index, + offset: name_offset, + })?; + aliases.push(ResearchAlias { + name_range: offset..offset + length, + row_index, + }); + } + cursor.require_eof()?; + Ok(aliases) +} + +fn read_relations( + archive: &NresDocument, + chunk_ids: &HashMap<u32, EntryId>, + count_type: u32, + index_type: u32, +) -> Result<ResearchRelations, ResearchError> { + let count_chunk = required_chunk(archive, chunk_ids, count_type)?; + let count_count = count_chunk.entry.meta().attr1; + let count_bytes = counted_prefix(count_chunk.payload, u64::from(count_count), 4)?; + let mut cursor = Cursor::new(count_bytes); + let max_items = u64::from(Limits::default().max_array_items); + let group_count = u64::from(count_count); + let offset_count = group_count + .checked_add(1) + .ok_or(DecodeError::IntegerOverflow)?; + let offset_capacity = checked_allocation_len( + offset_count, + max_items + .checked_add(1) + .ok_or(DecodeError::IntegerOverflow)?, + )?; + let mut group_starts = Vec::with_capacity(offset_capacity); + group_starts.push(0_usize); + let mut total_ids = 0_u64; + for _ in 0..count_count { + let count = cursor.read_u32_le()?; + total_ids = total_ids + .checked_add(u64::from(count)) + .ok_or(DecodeError::IntegerOverflow)?; + let next = checked_allocation_len(total_ids, max_items)?; + group_starts.push(next); + } + + let index_chunk = optional_chunk(archive, chunk_ids, index_type)?; + let indices = match index_chunk { + None if total_ids == 0 => Vec::new(), + None => { + return Err(ResearchError::MissingRelationStream { + type_id: index_type, + required_ids: total_ids, + }) + } + Some(chunk) => { + let index_bytes = counted_prefix(chunk.payload, total_ids, 4)?; + let mut cursor = Cursor::new(index_bytes); + let capacity = checked_allocation_len(total_ids, max_items)?; + let mut indices = Vec::with_capacity(capacity); + for _ in 0..total_ids { + indices.push(cursor.read_u32_le()?); + } + cursor.require_eof()?; + indices + } + }; + + Ok(ResearchRelations { + table_id: count_type, + group_starts, + indices, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decoded_array_limits_fail_before_large_allocations() { + let limit = Limits::default().max_array_items; + let too_many = limit.checked_add(1).expect("test count"); + + assert!(matches!( + read_research_rows(&[], too_many), + Err(ResearchError::Binary(DecodeError::LimitExceeded { count, limit: actual_limit })) + if count == u64::from(too_many) && actual_limit == u64::from(limit) + )); + assert!(matches!( + read_aliases(&[], &[], too_many), + Err(ResearchError::Binary(DecodeError::LimitExceeded { count, limit: actual_limit })) + if count == u64::from(too_many) && actual_limit == u64::from(limit) + )); + } +} diff --git a/crates/fparkan-font/Cargo.toml b/crates/fparkan-font/Cargo.toml new file mode 100644 index 0000000..3053a3e --- /dev/null +++ b/crates/fparkan-font/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fparkan-font" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-texm = { path = "../fparkan-texm" } + +[dev-dependencies] +fparkan-nres = { path = "../fparkan-nres" } diff --git a/crates/fparkan-font/src/lib.rs b/crates/fparkan-font/src/lib.rs new file mode 100644 index 0000000..770ebb0 --- /dev/null +++ b/crates/fparkan-font/src/lib.rs @@ -0,0 +1,402 @@ +#![forbid(unsafe_code)] +//! Safe reader for the fixed Tfnt metrics and its embedded Texm image. + +use fparkan_texm::{TexmDocument, TexmError}; +use std::sync::Arc; + +const PREFIX_LEN: usize = 0x14; +const GLYPH_TABLE_END: usize = 0x1014; +const GLYPH_COUNT: usize = 256; + +/// Confirmed Tfnt metrics and the preserved opaque word at offset `0x0c`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TfntHeader { + /// Lower bound used by the native fixed-width drawing path. + pub fixed_width_floor: i32, + /// Vertical extent used by native clipping and drawing. + pub vertical_extent: i32, + /// Raw bits copied from offset `0x0c`; its semantic name is unknown. + pub word_0c_bits: u32, + /// Per-input-byte spacing used by native text measurement. + pub character_spacing: i32, +} + +/// One 16-byte glyph record; the first three words remain opaque. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TfntGlyph { + /// First opaque DWORD. + pub word0: u32, + /// Second opaque DWORD. + pub word1: u32, + /// Third opaque DWORD. + pub word2: u32, + /// Signed advance at record offset `0x0c`. + pub advance: i32, +} + +/// Decoded Tfnt prefix, fixed glyph table, and embedded Texm document. +#[derive(Clone, Debug)] +pub struct TfntDocument { + /// Header metrics. + pub header: TfntHeader, + /// Records indexed by the native unsigned input byte, `0..=255`. + pub glyphs: [TfntGlyph; GLYPH_COUNT], + /// Texture document parsed by the shared Texm reader. + pub texm: TexmDocument, +} + +/// Tfnt decode error. +#[derive(Debug)] +pub enum TfntError { + /// The safe file reader requires the corpus signature `Tfnt`. + InvalidMagic { got: [u8; 4] }, + /// The fixed 20-byte prefix is incomplete. + TruncatedHeader { actual: usize, required: usize }, + /// The fixed 256-record table is incomplete. + TruncatedGlyphTable { actual: usize, required: usize }, + /// The embedded texture could not be decoded. + Texm(TexmError), +} + +impl std::fmt::Display for TfntError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidMagic { got } => write!(f, "invalid Tfnt magic: {got:02X?}"), + Self::TruncatedHeader { actual, required } => { + write!(f, "Tfnt header truncated: {actual} bytes, need {required}") + } + Self::TruncatedGlyphTable { actual, required } => { + write!( + f, + "Tfnt glyph table truncated: {actual} bytes, need {required}" + ) + } + Self::Texm(source) => write!(f, "embedded Texm: {source}"), + } + } +} + +impl std::error::Error for TfntError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Texm(source) => Some(source), + Self::InvalidMagic { .. } + | Self::TruncatedHeader { .. } + | Self::TruncatedGlyphTable { .. } => None, + } + } +} + +/// Decodes a Tfnt file and its embedded Texm texture. +/// +/// # Errors +/// +/// Returns [`TfntError`] for a bad signature, an incomplete fixed header or +/// glyph table, or an invalid embedded Texm document. +pub fn decode_tfnt(bytes: Arc<[u8]>) -> Result<TfntDocument, TfntError> { + if bytes.len() < PREFIX_LEN { + return Err(TfntError::TruncatedHeader { + actual: bytes.len(), + required: PREFIX_LEN, + }); + } + + let mut magic = [0; 4]; + magic.copy_from_slice(&bytes[..4]); + if magic != *b"Tfnt" { + return Err(TfntError::InvalidMagic { got: magic }); + } + + if bytes.len() < GLYPH_TABLE_END { + return Err(TfntError::TruncatedGlyphTable { + actual: bytes.len(), + required: GLYPH_TABLE_END, + }); + } + + let header = TfntHeader { + fixed_width_floor: read_i32_le(&bytes, 0x04), + vertical_extent: read_i32_le(&bytes, 0x08), + word_0c_bits: read_u32_le(&bytes, 0x0c), + character_spacing: read_i32_le(&bytes, 0x10), + }; + let mut glyphs = [TfntGlyph::default(); GLYPH_COUNT]; + for (glyph, record) in glyphs + .iter_mut() + .zip(bytes[PREFIX_LEN..GLYPH_TABLE_END].chunks_exact(16)) + { + *glyph = TfntGlyph { + word0: read_u32_le(record, 0), + word1: read_u32_le(record, 4), + word2: read_u32_le(record, 8), + advance: read_i32_le(record, 12), + }; + } + + let texm = + fparkan_texm::decode_texm(Arc::from(&bytes[GLYPH_TABLE_END..])).map_err(TfntError::Texm)?; + + Ok(TfntDocument { + header, + glyphs, + texm, + }) +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes( + bytes[offset..offset + 4] + .try_into() + .expect("fixed range was bounds-checked"), + ) +} + +fn read_i32_le(bytes: &[u8], offset: usize) -> i32 { + i32::from_le_bytes( + bytes[offset..offset + 4] + .try_into() + .expect("fixed range was bounds-checked"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_nres::{decode as decode_nres, ReadProfile}; + use fparkan_texm::PixelFormat; + use std::path::PathBuf; + + #[test] + fn reads_distinct_header_values_and_preserves_nan_bits() { + let mut bytes = tfnt_prefix(); + put_u32(&mut bytes, 0x04, (-17_i32) as u32); + put_u32(&mut bytes, 0x08, 513); + put_u32(&mut bytes, 0x0c, 0x7fc1_2345); + put_u32(&mut bytes, 0x10, (-3_i32) as u32); + bytes.extend_from_slice(&indexed_texm(2, 1, &[0xA5, 0x5A])); + + let document = decode_tfnt(Arc::from(bytes.into_boxed_slice())).expect("Tfnt"); + + assert_eq!(document.header.fixed_width_floor, -17); + assert_eq!(document.header.vertical_extent, 513); + assert_eq!(document.header.word_0c_bits, 0x7fc1_2345); + assert_eq!(document.header.character_spacing, -3); + } + + #[test] + fn preserves_distinct_glyph_records_including_byte_255_and_signed_advances() { + let mut bytes = tfnt_prefix(); + write_glyph( + &mut bytes, + 7, + [0x7fc1_2345, 0x89ab_cdef, 0x0102_0304, (-23_i32) as u32], + ); + write_glyph( + &mut bytes, + 255, + [0x8000_0000, 0x7654_3210, 0xffff_ffff, i32::MIN as u32], + ); + bytes.extend_from_slice(&indexed_texm(2, 1, &[1, 2])); + + let document = decode_tfnt(Arc::from(bytes.into_boxed_slice())).expect("Tfnt"); + + assert_eq!( + document.glyphs[7], + TfntGlyph { + word0: 0x7fc1_2345, + word1: 0x89ab_cdef, + word2: 0x0102_0304, + advance: -23, + } + ); + assert_eq!(document.glyphs[255].word0, 0x8000_0000); + assert_eq!(document.glyphs[255].word1, 0x7654_3210); + assert_eq!(document.glyphs[255].word2, u32::MAX); + assert_eq!(document.glyphs[255].advance, i32::MIN); + } + + #[test] + fn delegates_embedded_texture_to_texm_reader() { + let mut bytes = tfnt_prefix(); + bytes.extend_from_slice(&indexed_texm(2, 1, &[0xA5, 0x5A])); + + let document = decode_tfnt(Arc::from(bytes.into_boxed_slice())).expect("Tfnt"); + + assert_eq!(document.texm.width(), 2); + assert_eq!(document.texm.height(), 1); + assert_eq!(document.texm.mip_count(), 1); + assert_eq!(document.texm.format(), PixelFormat::Indexed8); + assert_eq!(document.texm.mip_level(0).expect("mip").bytes, [0xA5, 0x5A]); + } + + #[test] + fn rejects_wrong_magic_and_short_prefix() { + let mut wrong_magic = tfnt_prefix(); + wrong_magic[..4].copy_from_slice(b"BAD!"); + assert!(matches!( + decode_tfnt(Arc::from(wrong_magic.into_boxed_slice())), + Err(TfntError::InvalidMagic { .. }) + )); + + let mut short_prefix = tfnt_prefix(); + short_prefix.truncate(PREFIX_LEN - 1); + assert!(matches!( + decode_tfnt(Arc::from(short_prefix.into_boxed_slice())), + Err(TfntError::TruncatedHeader { .. }) + )); + } + + #[test] + fn rejects_short_fixed_glyph_table() { + let mut bytes = tfnt_prefix(); + bytes.truncate(GLYPH_TABLE_END - 1); + + assert!(matches!( + decode_tfnt(Arc::from(bytes.into_boxed_slice())), + Err(TfntError::TruncatedGlyphTable { .. }) + )); + } + + #[test] + fn reports_invalid_and_truncated_embedded_texm_without_panicking() { + let mut invalid = tfnt_prefix(); + invalid.extend_from_slice(b"Not a Texm payload, but long enough to reach its reader."); + assert!(matches!( + decode_tfnt(Arc::from(invalid.into_boxed_slice())), + Err(TfntError::Texm(_)) + )); + + let mut truncated = tfnt_prefix(); + truncated.extend_from_slice(b"Texm"); + assert!(matches!( + decode_tfnt(Arc::from(truncated.into_boxed_slice())), + Err(TfntError::Texm(_)) + )); + } + + #[test] + #[ignore = "requires licensed GOG corpus"] + fn decodes_original_gog_tfnt_resources() { + let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| panic!("FPARKAN_CORPUS_PART1_ROOT is required for this test")); + assert!( + root.is_dir(), + "licensed corpus root is missing: {}", + root.display() + ); + + let font_bytes = std::fs::read(root.join("ui/font.lib")).expect("read ui/font.lib"); + let fonts = decode_nres( + Arc::from(font_bytes.into_boxed_slice()), + ReadProfile::Strict, + ) + .expect("decode ui/font.lib"); + let expected = [ + ("gf_1024.tft", 256, 256), + ("tf_1024.tft", 256, 256), + ("mf_1024.tft", 256, 256), + ("gf_800.tft", 128, 128), + ("tf_800.tft", 128, 128), + ("mf_800.tft", 256, 256), + ("gf_640.tft", 128, 128), + ("tf_640.tft", 128, 128), + ("mf_640.tft", 256, 256), + ]; + assert_eq!(fonts.entry_count(), expected.len()); + for (name, width, height) in expected { + let id = fonts + .find(name) + .unwrap_or_else(|| panic!("missing NRes font {name}")); + let entry = fonts.entry(id).expect("font entry"); + assert_eq!(entry.meta().type_id, u32::from_le_bytes(*b"FONT"), "{name}"); + let document = decode_tfnt(Arc::from(fonts.payload(id).expect("font payload"))) + .unwrap_or_else(|err| panic!("{name}: {err}")); + assert_eq!(document.texm.width(), width, "{name}"); + assert_eq!(document.texm.height(), height, "{name}"); + assert_eq!(document.texm.format(), PixelFormat::Indexed8, "{name}"); + assert_eq!(document.texm.mip_count(), 1, "{name}"); + assert_eq!( + document.texm.mip_level(0).expect("mip").bytes.len(), + width as usize * height as usize, + "{name}" + ); + if name == "gf_640.tft" { + assert_eq!( + document.header, + TfntHeader { + fixed_width_floor: 8, + vertical_extent: 7, + word_0c_bits: 0x3d80_0000, + character_spacing: 1, + } + ); + assert_eq!( + document.glyphs[65], + TfntGlyph { + word0: 0x3c00_0000, + word1: 0x3d80_0000, + word2: 0x3e80_0000, + advance: 6, + } + ); + assert_eq!(document.glyphs[255].advance, 5); + } + } + + let sys_bytes = std::fs::read(root.join("sys.lib")).expect("read sys.lib"); + let sys = decode_nres(Arc::from(sys_bytes.into_boxed_slice()), ReadProfile::Strict) + .expect("decode sys.lib"); + let id = sys.find("ARIALTEX.TFT").expect("ARIALTEX.TFT entry"); + let entry = sys.entry(id).expect("ARIALTEX.TFT entry"); + assert_eq!(entry.meta().type_id, u32::from_le_bytes(*b"FONT")); + let document = decode_tfnt(Arc::from(sys.payload(id).expect("ARIALTEX.TFT payload"))) + .expect("decode ARIALTEX.TFT"); + assert_eq!(document.texm.width(), 128); + assert_eq!(document.texm.height(), 128); + assert_eq!(document.texm.format(), PixelFormat::Indexed8); + assert_eq!(document.texm.mip_count(), 1); + assert_eq!( + document.texm.mip_level(0).expect("mip").bytes.len(), + 128 * 128 + ); + assert_eq!( + document.header, + TfntHeader { + fixed_width_floor: 8, + vertical_extent: 7, + word_0c_bits: 0x3d80_0000, + character_spacing: 1, + } + ); + assert_eq!(document.glyphs[65].advance, 6); + assert_eq!(document.glyphs[255].advance, 4); + } + + fn tfnt_prefix() -> Vec<u8> { + let mut bytes = vec![0; GLYPH_TABLE_END]; + bytes[..4].copy_from_slice(b"Tfnt"); + bytes + } + + fn indexed_texm(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> { + let mut bytes = Vec::with_capacity(32 + 1024 + pixels.len()); + for word in [0x6d78_6554, width, height, 1, 0x20, 0x0100_0000, 0, 0] { + bytes.extend_from_slice(&word.to_le_bytes()); + } + bytes.extend_from_slice(&[0; 1024]); + bytes.extend_from_slice(pixels); + bytes + } + + fn put_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn write_glyph(bytes: &mut [u8], index: usize, words: [u32; 4]) { + let offset = PREFIX_LEN + index * 16; + for (word_index, value) in words.into_iter().enumerate() { + put_u32(bytes, offset + word_index * 4, value); + } + } +} diff --git a/crates/fparkan-fx/src/ambient_op3.rs b/crates/fparkan-fx/src/ambient_op3.rs index 5bd2881..1ec2973 100644 --- a/crates/fparkan-fx/src/ambient_op3.rs +++ b/crates/fparkan-fx/src/ambient_op3.rs @@ -471,6 +471,7 @@ fn native_op3_type1_matrix( /// `view · axis` value to exactly ±1 before building the cross-product frame; /// neither input vector is normalized. The native path normalizes only /// `view × axis`, then computes `axis × U` without normalizing the result. +/// The authored axis is the quad's X column (Effect.dll RVA 0x97F6..0x983C). #[must_use] fn native_op3_type1_basis( view: [f32; 3], @@ -501,9 +502,9 @@ fn native_op3_type1_basis( } let w = cross3(axis, u); let result = [ - [u[0], w[0], axis[0], position[0]], - [u[1], w[1], axis[1], position[1]], - [u[2], w[2], axis[2], position[2]], + [axis[0], u[0], w[0], position[0]], + [axis[1], u[1], w[1], position[1]], + [axis[2], u[2], w[2], position[2]], [0.0, 0.0, 0.0, 1.0], ]; result @@ -837,19 +838,19 @@ mod tests { // cross-product frame instead of being normalized before the gate. let frame = native_op3_type1_basis([2.0, 1.0, 0.0], [1.0, 0.0, 0.0], [3.0, 4.0, 5.0]).unwrap(); - assert_eq!(frame[0], [0.0, 0.0, 1.0, 3.0]); - assert_eq!(frame[1], [0.0, 1.0, 0.0, 4.0]); - assert_eq!(frame[2], [-1.0, 0.0, 0.0, 5.0]); + assert_eq!(frame[0], [1.0, 0.0, 0.0, 3.0]); + assert_eq!(frame[1], [0.0, 0.0, 1.0, 4.0]); + assert_eq!(frame[2], [0.0, -1.0, 0.0, 5.0]); // The axis is not normalized: W and the axis column keep its scale. let axis_scaled = native_op3_type1_basis([2.0, 1.0, 0.0], [0.0, 0.0, 2.0], [0.0; 3]).unwrap(); let inv_sqrt_5 = 1.0 / 5.0_f32.sqrt(); - assert_eq!(axis_scaled[0][0], inv_sqrt_5); - assert_eq!(axis_scaled[1][0], -2.0 * inv_sqrt_5); - assert_eq!(axis_scaled[0][1], 4.0 * inv_sqrt_5); - assert_eq!(axis_scaled[1][1], 2.0 * inv_sqrt_5); - assert_eq!(axis_scaled[2][2], 2.0); + assert_eq!(axis_scaled[0][1], inv_sqrt_5); + assert_eq!(axis_scaled[1][1], -2.0 * inv_sqrt_5); + assert_eq!(axis_scaled[0][2], 4.0 * inv_sqrt_5); + assert_eq!(axis_scaled[1][2], 2.0 * inv_sqrt_5); + assert_eq!(axis_scaled[2][0], 2.0); assert_eq!( native_op3_type1_basis([f32::NAN, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0; 3]), @@ -907,6 +908,10 @@ mod tests { assert_eq!(frame.normalized_time, 0.5); assert_eq!(frame.color, [1.0, 1.0, 1.0, 0.5]); assert_eq!(frame.material_phase, 0.5); + let authored_axis_edge = std::array::from_fn::<_, 3, _>(|axis| { + frame.vertices[2][axis] - frame.vertices[1][axis] + }); + assert_eq!(authored_axis_edge, [0.0, 0.0, 1.0]); assert!(evaluate_op3_type1_command( &command, 4, @@ -919,6 +924,197 @@ mod tests { } #[test] + #[ignore = "requires the original GOG installation"] + fn original_deflector_quad_keeps_its_authored_axis_over_time_and_camera_motion() { + let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation"); + let bytes = std::fs::read(std::path::Path::new(&root).join("effects.rlb")) + .expect("read effects.rlb"); + let archive = fparkan_nres::decode(bytes.into(), fparkan_nres::ReadProfile::Compatible) + .expect("decode effects.rlb"); + let entry = archive.find("deflector").expect("deflector FXID"); + let document = crate::decode_fxid( + archive + .payload(entry) + .expect("FXID payload") + .to_vec() + .into(), + ) + .expect("decode deflector"); + let command = &document.commands()[0]; + let parent = [ + [12.0, 0.0, 0.0, 100.0], + [0.0, 3.0, 0.0, 200.0], + [0.0, 0.0, 3.0, 20.0], + [0.0, 0.0, 0.0, 1.0], + ]; + for elapsed in [0.0_f32, 0.125, 0.5, 1.0, 2.5] { + for camera in [ + [160.0, 230.0, 20.0], + [160.0, 200.0, 50.0], + [160.0, 170.0, 5.0], + ] { + let frame = evaluate_op3_type1_command( + command, + 0, + &parent, + elapsed, + elapsed.min(1.0), + camera, + ) + .expect("active deflector quad"); + assert!(frame.active); + assert_eq!(frame.material.name.to_ascii_lowercase(), "defl_b"); + let axis_edge = std::array::from_fn::<_, 3, _>(|axis| { + frame.vertices[2][axis] - frame.vertices[1][axis] + }); + assert_eq!(axis_edge, [12.0, 0.0, 0.0]); + let center = std::array::from_fn::<_, 3, _>(|axis| { + (frame.vertices[0][axis] + frame.vertices[2][axis]) * 0.5 + }); + assert_eq!(center, [106.0, 200.0, 20.0]); + assert_eq!(frame.material_phase, (elapsed * 7.0).fract()); + } + } + } + + #[test] + #[ignore = "requires the original GOG installation"] + fn original_tree_ray_58_matches_native_type1_geometry_samples() { + let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation"); + let bytes = std::fs::read(std::path::Path::new(&root).join("effects.rlb")) + .expect("read effects.rlb"); + let archive = fparkan_nres::decode(bytes.into(), fparkan_nres::ReadProfile::Compatible) + .expect("decode effects.rlb"); + let entry = archive.find("tree_ray_58").expect("tree_ray_58 FXID"); + let document = crate::decode_fxid( + archive + .payload(entry) + .expect("FXID payload") + .to_vec() + .into(), + ) + .expect("decode tree_ray_58"); + assert_eq!(document.header().time_mode, 2); + assert_eq!(document.header().duration_seconds, 3.0); + assert_eq!(document.commands().len(), 3); + + let parent = [ + [0.999_924_123, 0.012_319_660, 0.0, 386.673_400_879], + [-0.012_319_660, 0.999_924_123, 0.0, 603.692_321_777], + [0.0, 0.0, 1.0, 180.414_703_369], + [0.0, 0.0, 0.0, 1.0], + ]; + let center = [390.273_132, 603.647_949, 180.414_703]; + // Original GOG Effect.dll RVA 0x93D0, x87 CW 0x007F; Rust-sampled keys are supplied separately. + let native_samples = [ + ( + 0, + "defl_y_add", + [0.0, 35.0, 22.0], + [ + [386.280, 604.229_431, 179.568_024], + [386.266_876, 603.165_039, 181.261_383], + [394.266_266, 603.066_467, 181.261_383], + [394.279_4, 604.130_859, 179.568_024], + ], + ), + ( + 0, + "defl_y_add", + [20.0, -30.0, 15.0], + [ + [386.279, 604.147_4, 181.307_663], + [386.267_883, 603.247_1, 179.521_744], + [394.267_273, 603.148_5, 179.521_744], + [394.278_381, 604.048_8, 181.307_663], + ], + ), + ( + 1, + "defl_y1_add", + [0.0, 35.0, 22.0], + [ + [386.276_7, 603.963_3, 179.991_364], + [386.270_172, 603.431_152, 180.838_043], + [394.269_562, 603.332_6, 180.838_043], + [394.276_1, 603.864_746, 179.991_364], + ], + ), + ]; + for (command_index, material_name, camera_offset, expected_vertices) in native_samples { + let camera = std::array::from_fn(|axis| center[axis] + camera_offset[axis]); + let frame = evaluate_op3_type1_command( + &document.commands()[command_index], + command_index, + &parent, + 0.0, + 0.0, + camera, + ) + .expect("active tree ray quad"); + assert_eq!(frame.material.name, material_name); + for (actual, expected) in frame + .vertices + .iter() + .flatten() + .zip(expected_vertices.iter().flatten()) + { + assert!( + (actual - expected).abs() < 0.001, + "native sample vertex mismatch: {actual} vs {expected}" + ); + } + } + + for (command_index, material_name) in [(0, "defl_y_add"), (1, "defl_y1_add")] { + let command = &document.commands()[command_index]; + assert_eq!(command.opcode, FxOpcode::Op3); + assert_eq!( + read_u32(&command.raw_body, 0), + Some(TYPE1_BODY_DISCRIMINATOR) + ); + let camera_offsets = [[0.0, 35.0, 22.0], [20.0, -30.0, 15.0], [-25.0, 18.0, 10.0]]; + for elapsed in [0.0_f32, 0.375, 0.75, 1.5, 2.25, 2.999, 3.125] { + let phase = elapsed.rem_euclid(3.0) / 3.0; + for camera_offset in camera_offsets { + let camera = std::array::from_fn(|axis| center[axis] + camera_offset[axis]); + let frame = evaluate_op3_type1_command( + command, + command_index, + &parent, + elapsed, + phase, + camera, + ) + .expect("active tree ray quad"); + assert_eq!(frame.material.name, material_name); + assert!(frame.active); + let actual_center: [f32; 3] = std::array::from_fn(|axis| { + frame + .vertices + .iter() + .map(|vertex| vertex[axis]) + .sum::<f32>() + * 0.25 + }); + for axis in 0..3 { + assert!((actual_center[axis] - center[axis]).abs() < 0.001); + } + let authored_axis_edge: [f32; 3] = std::array::from_fn(|axis| { + frame.vertices[2][axis] - frame.vertices[1][axis] + }); + let native_axis_edge = [7.999_393, -0.098_557_28, 0.0]; + for axis in 0..3 { + assert!((authored_axis_edge[axis] - native_axis_edge[axis]).abs() < 0.001); + } + } + } + } + } + + #[test] fn rejects_truncated_bodies_and_non_finite_tracks_or_inputs() { let identity = [ [1.0, 0.0, 0.0, 0.0], diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs index 4b561e7..f9d7555 100644 --- a/crates/fparkan-msh/src/lib.rs +++ b/crates/fparkan-msh/src/lib.rs @@ -21,8 +21,13 @@ pub const STREAM_UV0: u32 = 5; /// Optional secondary texture coordinate stream used by native lightmap/detail /// stages. MSH stores it as the type-18 four-byte packed UV stream. pub const STREAM_UV1: u32 = 18; +/// Optional type-16 stream. Its presence marks MSH vertex-colour data. +pub const STREAM_TYPE_16: u32 = 16; /// Triangle index stream. pub const STREAM_INDICES: u32 = 6; +/// Optional triangle-descriptor stream. +pub const STREAM_TRIANGLE_DESCRIPTORS: u32 = 7; +const TRIANGLE_NORMAL_SCALE: f32 = 1.0 / 32_767.0; /// Animation key stream. pub const STREAM_ANIMATION_KEYS: u32 = 8; /// Node names stream. @@ -98,10 +103,16 @@ pub struct ModelAsset { pub uv0: Option<Vec<[u16; 2]>>, /// Optional secondary packed texture coordinates from type 18. pub uv1: Option<Vec<[u16; 2]>>, + /// Whether the source MSH contains a type-16 vertex-colour stream. + /// + /// The stream payload is preserved separately and is not decoded here. + pub type16_present: bool, /// Triangle indices. pub indices: Vec<u16>, /// Draw batches. pub batches: Vec<Batch>, + /// Optional type-7 triangle descriptors. + pub triangle_descriptors: Option<Vec<TriangleDescriptor>>, /// Optional decoded node names. pub node_names: Option<Vec<Option<String>>>, /// Optional decoded node-animation streams. @@ -189,7 +200,13 @@ pub struct Batch { pub material_index: u8, /// Lightmap selector at byte offset `+0x05`. pub lightmap_index: u8, - /// Local batch index at byte offset `+0x06`. + /// Raw word at byte offset `+0x06`. + /// + /// In the native building-collision helper retry, this word together with + /// the model's base index selects the next linked Node38 root. For a + /// standalone `ModelAsset`, that base is zero, so the link is this raw + /// value. Keep the decoded field lossless; its meaning depends on the + /// query path and batch flags. pub local_batch_index: u16, /// Index count. pub index_count: u16, @@ -201,9 +218,48 @@ pub struct Batch { pub base_vertex: u32, } -/// Preserved triangle descriptor stream marker. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TriangleDescriptor; +/// One 16-byte type-7 triangle descriptor. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TriangleDescriptor { + /// Triangle mask flags at byte offset `+0`. + pub tri_flags: u16, + /// Opaque 16-bit words from offsets `+2`, `+4`, `+6` and `+14`. + pub opaque: [u16; 4], + /// Signed packed plane normal at byte offsets `+8`, `+10` and `+12`. + pub normal: [i16; 3], +} + +/// Include-all and exclude-any masks used by the native MSH query. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SegmentQueryMasks { + /// Node38 header bits that must all be set. + pub node_include: u16, + /// Node38 header bits that must all be clear. + pub node_exclude: u16, + /// Batch20 leading-DWORD bits that must all be set. + pub batch_include: u32, + /// Batch20 leading-DWORD bits that must all be clear. + pub batch_exclude: u32, + /// Type-7 triangle bits that must all be set. + pub triangle_include: u16, + /// Type-7 triangle bits that must all be clear. + pub triangle_exclude: u16, +} + +/// One accepted segment hit from the native MSH triangle-query path. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SegmentHit { + /// Hit position in the same coordinate frame as the input segment. + pub position: [f32; 3], + /// Squared distance from the input segment's first endpoint. + pub distance_squared: f32, + /// Global Batch20 index. + pub batch_index: usize, + /// Global type-7 descriptor index. + pub triangle_descriptor_index: usize, + /// Global Res3 position indices of the hit triangle. + pub vertex_indices: [usize; 3], +} /// Vertex stream view. #[derive(Clone, Debug, PartialEq)] @@ -685,6 +741,287 @@ pub fn draw_batches(model: &ModelAsset, slot: SlotId) -> Result<&[Batch], MshErr }) } +/// Queries a segment against one slot using the native Node38, Batch20 and +/// type-7 include-all/exclude-any masks. +/// +/// The first endpoint is the native forward direction. Batch bit `0x0002` +/// retries the plane test in reverse only when the forward plane test misses. +/// The returned distance is always measured from the original first endpoint. +/// +/// # Errors +/// +/// Returns [`MshError`] when type 7 is absent, the model does not use Node38, +/// or the selected slot's descriptor, batch, index or vertex ranges are invalid. +pub fn query_slot_segment( + model: &ModelAsset, + node: NodeId, + slot: SlotId, + endpoints: [[f32; 3]; 2], + masks: SegmentQueryMasks, +) -> Result<Option<SegmentHit>, MshError> { + let descriptors = model + .triangle_descriptors + .as_deref() + .ok_or(MshError::MissingStream { + type_id: STREAM_TRIANGLE_DESCRIPTORS, + label: "Res7", + })?; + if model.node_stride != 38 { + return Err(MshError::InvalidGeometry(format!( + "segment query requires Node38 metadata, got node stride {}", + model.node_stride + ))); + } + if !endpoints.iter().flatten().all(|value| value.is_finite()) { + return Err(MshError::InvalidGeometry( + "segment endpoints must be finite".to_string(), + )); + } + let node_flags = node38_metadata(model, node) + .ok_or_else(|| MshError::InvalidGeometry("invalid Node38 metadata".to_string()))? + .header_flags; + let slot_index = usize::try_from(slot.0).map_err(|_| MshError::SlotOutOfBounds { + slot: slot.0, + slot_count: model.slots.len(), + })?; + let slot_ref = model + .slots + .get(slot_index) + .ok_or(MshError::SlotOutOfBounds { + slot: slot.0, + slot_count: model.slots.len(), + })?; + let batches = draw_batches(model, slot)?; + let descriptor_start = usize::from(slot_ref.tri_start); + let descriptor_end = descriptor_start + .checked_add(usize::from(slot_ref.tri_count)) + .ok_or_else(|| MshError::InvalidGeometry("type-7 range overflow".to_string()))?; + if descriptor_end > descriptors.len() { + return Err(MshError::InvalidGeometry(format!( + "slot {slot_index} type-7 range {descriptor_start}..{descriptor_end} exceeds {} descriptors", + descriptors.len() + ))); + } + + let mut face_count = 0usize; + for (local_batch, batch) in batches.iter().enumerate() { + if batch.index_count % 3 != 0 { + return Err(MshError::InvalidGeometry(format!( + "batch {} index count {} is not triangle-aligned", + usize::from(slot_ref.batch_start) + local_batch, + batch.index_count + ))); + } + let start = usize::try_from(batch.index_start) + .map_err(|_| MshError::InvalidGeometry("batch index range overflow".to_string()))?; + let end = start + .checked_add(usize::from(batch.index_count)) + .ok_or_else(|| MshError::InvalidGeometry("batch index range overflow".to_string()))?; + if end > model.indices.len() { + return Err(MshError::InvalidGeometry(format!( + "batch {} index range {start}..{end} exceeds {} indices", + usize::from(slot_ref.batch_start) + local_batch, + model.indices.len() + ))); + } + face_count = face_count + .checked_add(usize::from(batch.index_count) / 3) + .ok_or_else(|| MshError::InvalidGeometry("slot triangle count overflow".to_string()))?; + } + if face_count != usize::from(slot_ref.tri_count) { + return Err(MshError::InvalidGeometry(format!( + "slot {slot_index} declares {} type-7 descriptors but its batches contain {face_count} triangles", + slot_ref.tri_count + ))); + } + if !mask_matches( + u32::from(node_flags), + u32::from(masks.node_include), + u32::from(masks.node_exclude), + ) { + return Ok(None); + } + + let mut best = None; + let mut descriptor_cursor = descriptor_start; + for (local_batch, batch) in batches.iter().enumerate() { + let batch_index = usize::from(slot_ref.batch_start) + local_batch; + let batch_flags = u32::from(batch.batch_flags) | (u32::from(batch.material_index_hi) << 16); + let batch_face_count = usize::from(batch.index_count) / 3; + if mask_matches(batch_flags, masks.batch_include, masks.batch_exclude) { + for local_face in 0..batch_face_count { + let triangle_descriptor_index = descriptor_cursor + local_face; + let descriptor = descriptors[triangle_descriptor_index]; + if !mask_matches( + u32::from(descriptor.tri_flags), + u32::from(masks.triangle_include), + u32::from(masks.triangle_exclude), + ) { + continue; + } + let index_start = usize::try_from(batch.index_start) + .expect("validated batch index range") + + local_face * 3; + let base_vertex = usize::try_from(batch.base_vertex) + .map_err(|_| MshError::InvalidGeometry("base vertex overflow".to_string()))?; + let mut vertex_indices = [0usize; 3]; + for (triangle_corner, index) in vertex_indices.iter_mut().enumerate() { + let raw_index = usize::from(model.indices[index_start + triangle_corner]); + *index = base_vertex.checked_add(raw_index).ok_or_else(|| { + MshError::InvalidGeometry("resolved vertex index overflow".to_string()) + })?; + if *index >= model.positions.len() { + return Err(MshError::VertexIndexOutOfBounds { + batch: batch_index, + vertex: u64::try_from(*index).unwrap_or(u64::MAX), + position_count: model.positions.len(), + }); + } + } + let triangle = vertex_indices.map(|index| model.positions[index]); + if !triangle.iter().flatten().all(|value| value.is_finite()) { + return Err(MshError::InvalidGeometry(format!( + "triangle descriptor {triangle_descriptor_index} has a non-finite position" + ))); + } + let normal = descriptor + .normal + .map(|value| f32::from(value) * TRIANGLE_NORMAL_SCALE); + if normal == [0.0; 3] { + continue; + } + let forward = + native_segment_plane_hit(normal, triangle[0], endpoints[0], endpoints[1])?; + let position = match forward { + Some(position) => Some(position), + None if batch.batch_flags & 0x0002 != 0 => { + native_segment_plane_hit(normal, triangle[0], endpoints[1], endpoints[0])? + } + None => None, + }; + let Some(position) = position else { + continue; + }; + if !native_point_in_triangle(normal, triangle, position)? { + continue; + } + let offset = [ + position[0] - endpoints[0][0], + position[1] - endpoints[0][1], + position[2] - endpoints[0][2], + ]; + let distance_squared = dot3(offset, offset); + if !distance_squared.is_finite() { + return Err(MshError::InvalidGeometry( + "segment hit distance is non-finite".to_string(), + )); + } + if best.is_none_or(|hit: SegmentHit| distance_squared <= hit.distance_squared) { + best = Some(SegmentHit { + position, + distance_squared, + batch_index, + triangle_descriptor_index, + vertex_indices, + }); + } + } + } + descriptor_cursor += batch_face_count; + } + Ok(best) +} + +fn mask_matches(flags: u32, include: u32, exclude: u32) -> bool { + flags & include == include && flags & exclude == 0 +} + +fn native_segment_plane_hit( + normal: [f32; 3], + anchor: [f32; 3], + start: [f32; 3], + end: [f32; 3], +) -> Result<Option<[f32; 3]>, MshError> { + let direction = [end[0] - start[0], end[1] - start[1], end[2] - start[2]]; + let denominator = dot3(normal, direction); + let start_distance = dot3(normal, start) - dot3(normal, anchor); + let end_distance = start_distance + denominator; + if !denominator.is_finite() || !start_distance.is_finite() || !end_distance.is_finite() { + return Err(MshError::InvalidGeometry( + "segment plane calculation is non-finite".to_string(), + )); + } + if denominator >= 0.0 || start_distance <= 0.0 || end_distance > 0.0 { + return Ok(None); + } + let ratio = start_distance / denominator; + let position = [ + start[0] - direction[0] * ratio, + start[1] - direction[1] * ratio, + start[2] - direction[2] * ratio, + ]; + if !position.iter().all(|value| value.is_finite()) { + return Err(MshError::InvalidGeometry( + "segment intersection is non-finite".to_string(), + )); + } + Ok(Some(position)) +} + +fn native_point_in_triangle( + normal: [f32; 3], + triangle: [[f32; 3]; 3], + point: [f32; 3], +) -> Result<bool, MshError> { + let absolute = normal.map(f32::abs); + let xy_max = if absolute[0] < absolute[1] { + absolute[1] + } else { + absolute[0] + }; + let dominant_axis = if absolute[2] <= xy_max { + if absolute[0] < absolute[1] { + 1 + } else { + 0 + } + } else { + 2 + }; + for index in 0..3 { + let previous = triangle[(index + 2) % 3]; + let current = triangle[index]; + let relative = [ + point[0] - previous[0], + point[1] - previous[1], + point[2] - previous[2], + ]; + let edge = [ + current[0] - previous[0], + current[1] - previous[1], + current[2] - previous[2], + ]; + let side = match dominant_axis { + 0 => (relative[2] * edge[1] + relative[1] * -edge[2]) * normal[0], + 1 => (-edge[0] * relative[2] + relative[0] * edge[2]) * normal[1], + _ => (relative[1] * edge[0] - relative[0] * edge[1]) * normal[2], + }; + if !side.is_finite() { + return Err(MshError::InvalidGeometry( + "triangle edge calculation is non-finite".to_string(), + )); + } + if side < 0.0 { + return Ok(false); + } + } + Ok(true) +} + +fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 { + left[0] * right[0] + left[1] * right[1] + left[2] * right[2] +} + impl MshDocument { /// Returns original stream descriptors. #[must_use] @@ -791,6 +1128,10 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError> let uv1 = read_optional_stream(document, STREAM_UV1)? .map(|raw| parse_secondary_uv_stream(raw, positions.len())) .transpose()?; + let type16_present = read_optional_stream(document, STREAM_TYPE_16)?.is_some(); + let triangle_descriptors = read_optional_triangle_descriptors(document)? + .map(|raw| parse_triangle_descriptors(&raw.bytes)) + .transpose()?; let node_names = read_optional_stream(document, STREAM_NAMES)? .map(|raw| parse_res10_names(&raw.bytes, node_count)) .transpose()?; @@ -807,8 +1148,10 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError> normals, uv0, uv1, + type16_present, indices, batches, + triangle_descriptors, node_names, animation, }) @@ -879,6 +1222,28 @@ fn read_optional_stream( })) } +fn read_optional_triangle_descriptors( + document: &NresDocument, +) -> Result<Option<RawStream>, MshError> { + let mut entries = document + .entries() + .iter() + .filter(|entry| entry.meta().type_id == STREAM_TRIANGLE_DESCRIPTORS); + let Some(entry) = entries.next() else { + return Ok(None); + }; + if entries.next().is_some() { + return Err(MshError::DuplicateStream { + type_id: STREAM_TRIANGLE_DESCRIPTORS, + label: "Res7", + }); + } + Ok(Some(RawStream { + attributes: attributes(entry.meta()), + bytes: document.payload(entry.id())?.to_vec(), + })) +} + fn parse_slots(data: &[u8]) -> Result<Vec<Slot>, MshError> { if data.len() < 0x8C { return Err(MshError::InvalidGeometry(format!( @@ -997,6 +1362,30 @@ fn parse_batches(data: &[u8]) -> Result<Vec<Batch>, MshError> { Ok(out) } +fn parse_triangle_descriptors(data: &[u8]) -> Result<Vec<TriangleDescriptor>, MshError> { + if !data.len().is_multiple_of(16) { + return Err(invalid_resource_size("Res7", data.len(), 16)); + } + let mut out = Vec::with_capacity(data.len() / 16); + for offset in (0..data.len()).step_by(16) { + out.push(TriangleDescriptor { + tri_flags: read_u16_required(data, offset)?, + opaque: [ + read_u16_required(data, offset + 2)?, + read_u16_required(data, offset + 4)?, + read_u16_required(data, offset + 6)?, + read_u16_required(data, offset + 14)?, + ], + normal: [ + read_i16_required(data, offset + 8)?, + read_i16_required(data, offset + 10)?, + read_i16_required(data, offset + 12)?, + ], + }); + } + Ok(out) +} + fn parse_u16_array(data: &[u8], label: &'static str) -> Result<Vec<u16>, MshError> { if !data.len().is_multiple_of(2) { return Err(invalid_resource_size(label, data.len(), 2)); @@ -1212,6 +1601,12 @@ fn read_u16_required(bytes: &[u8], offset: usize) -> Result<u16, MshError> { Ok(u16::from_le_bytes(arr)) } +fn read_i16_required(bytes: &[u8], offset: usize) -> Result<i16, MshError> { + Ok(i16::from_le_bytes( + read_u16_required(bytes, offset)?.to_le_bytes(), + )) +} + fn read_i8(bytes: &[u8], offset: usize) -> Result<i8, MshError> { let byte = bytes .get(offset) @@ -1259,6 +1654,371 @@ mod tests { assert_eq!(model.node_stride, 38); assert_eq!(model.node_count, 0); assert!(model.slots.is_empty()); + assert_eq!(model.triangle_descriptors, None); + assert!(!model.type16_present); + } + + #[test] + fn type16_presence_is_preserved_without_decoding_its_payload() { + let payload = [0xDE, 0xAD, 0xBE, 0xEF]; + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream(STREAM_TYPE_16, 0, b"Res16", &payload), + ])) + .expect("nested NRes"); + let model = + validate_msh(&decode_msh(&document).expect("MSH document")).expect("model asset"); + + assert!(model.type16_present); + } + + #[test] + fn type7_decodes_optional_records_and_keeps_opaque_words() { + let mut raw = Vec::new(); + push_u16(&mut raw, 0x3456); + push_u16(&mut raw, 0x1111); + push_u16(&mut raw, 0x2222); + push_u16(&mut raw, 0x3333); + raw.extend_from_slice(&(-1_i16).to_le_bytes()); + raw.extend_from_slice(&i16::MIN.to_le_bytes()); + raw.extend_from_slice(&i16::MAX.to_le_bytes()); + push_u16(&mut raw, 0x4444); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7", &raw), + ])) + .expect("nested NRes"); + let msh = decode_msh(&document).expect("optional type7 stream"); + let model = validate_msh(&msh).expect("model"); + + assert_eq!( + model.triangle_descriptors, + Some(vec![TriangleDescriptor { + tri_flags: 0x3456, + opaque: [0x1111, 0x2222, 0x3333, 0x4444], + normal: [-1, i16::MIN, i16::MAX], + }]) + ); + let preserved = msh.preserved_streams().expect("preserved streams"); + assert_eq!( + preserved + .iter() + .find(|stream| stream.type_id == STREAM_TRIANGLE_DESCRIPTORS) + .unwrap() + .bytes + .as_ref(), + raw + ); + } + + #[test] + fn malformed_or_duplicate_optional_type7_stream_is_an_error() { + for entries in [ + vec![ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7", &[0; 15]), + ], + vec![ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7a", &[0; 16]), + stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7b", &[0; 16]), + ], + ] { + let document = decode_nested(&build_nres(&entries)).expect("nested NRes"); + let msh = decode_msh(&document).expect("required streams"); + assert!(validate_msh(&msh).is_err()); + } + } + + #[test] + fn segment_query_uses_native_one_sided_edge_and_endpoint_rules() { + let model = segment_model(&[(0, 0)], &[0]); + let hit = query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("forward segment") + .expect("front-to-back hit"); + assert_eq!(hit.position, [0.0, 0.0, 0.0]); + assert_eq!(hit.distance_squared, 1.0); + assert_eq!(hit.vertex_indices, [0, 1, 2]); + + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, -1.0, 0.0], [0.0, 1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("reverse segment") + .is_none()); + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, 0.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("endpoint on plane") + .is_some()); + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 0.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("start on plane") + .is_none()); + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, -1.0], [0.0, -1.0, -1.0]], + SegmentQueryMasks::default(), + ) + .expect("segment crossing triangle edge") + .is_some()); + + let two_sided = segment_model(&[(2, 0)], &[0]); + assert!(query_slot_segment( + &two_sided, + NodeId(0), + SlotId(0), + [[0.0, -1.0, 0.0], [0.0, 1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("two-sided reverse retry") + .is_some()); + } + + #[test] + fn segment_query_skips_triangles_with_zero_packed_normals() { + let mut model = segment_model(&[(0, 0)], &[0]); + model + .triangle_descriptors + .as_mut() + .expect("type-7 descriptors")[0] + .normal = [0; 3]; + + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("zero normal is a missed triangle") + .is_none()); + } + + #[test] + fn segment_query_applies_native_masks_and_advances_filtered_descriptors() { + let mut model = segment_model(&[(0x0200, 1), (0, 1), (0, 1)], &[0, 0x0020, 0x0004]); + model + .triangle_descriptors + .as_mut() + .expect("type-7 descriptors") + .insert( + 0, + TriangleDescriptor { + tri_flags: 0, + opaque: [0; 4], + normal: [0, i16::MAX, 0], + }, + ); + model.slots[0].tri_start = 1; + let masks = SegmentQueryMasks { + node_include: 0x0004, + node_exclude: 0, + batch_include: 0x0001_0000, + batch_exclude: 0x0000_0200, + triangle_include: 0x0004, + triangle_exclude: 0x0020, + }; + let hit = query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + masks, + ) + .expect("native masks") + .expect("third descriptor survives"); + assert_eq!(hit.batch_index, 2); + assert_eq!(hit.triangle_descriptor_index, 3); + assert_eq!(hit.vertex_indices, [6, 7, 8]); + + for masks in [ + SegmentQueryMasks { + node_include: 0x0008, + ..masks + }, + SegmentQueryMasks { + node_exclude: 0x0004, + ..masks + }, + SegmentQueryMasks { + batch_include: 0x0002_0000, + ..masks + }, + SegmentQueryMasks { + triangle_exclude: 0x0004, + ..masks + }, + ] { + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + masks, + ) + .expect("filtered query") + .is_none()); + } + } + + #[test] + fn segment_query_replaces_equal_distance_hits_with_the_later_candidate() { + let model = segment_model(&[(0, 0), (0, 0)], &[0, 0]); + let hit = query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .expect("equal-distance candidates") + .expect("hit"); + assert_eq!(hit.distance_squared, 1.0); + assert_eq!(hit.batch_index, 1); + assert_eq!(hit.triangle_descriptor_index, 1); + } + + #[test] + fn native_point_in_triangle_projects_on_each_dominant_normal_axis() { + for (normal, triangle, edge, outside) in [ + ( + [1.0, 0.0, 0.0], + [[0.0, -1.0, -1.0], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]], + [0.0, 0.0, -1.0], + [0.0, 2.0, 0.0], + ), + ( + [0.0, 1.0, 0.0], + [[-1.0, 0.0, -1.0], [0.0, 0.0, 1.0], [1.0, 0.0, -1.0]], + [0.0, 0.0, -1.0], + [0.0, 0.0, 2.0], + ), + ( + [0.0, 0.0, 1.0], + [[-1.0, -1.0, 0.0], [1.0, -1.0, 0.0], [0.0, 1.0, 0.0]], + [0.0, -1.0, 0.0], + [2.0, 0.0, 0.0], + ), + ] { + assert!(native_point_in_triangle(normal, triangle, [0.0; 3]).expect("inside")); + assert!(native_point_in_triangle(normal, triangle, edge).expect("edge")); + assert!(!native_point_in_triangle(normal, triangle, outside).expect("outside")); + } + } + + #[test] + fn segment_query_reports_missing_type7_and_invalid_metadata_or_ranges() { + let mut model = segment_model(&[(0, 0)], &[0]); + model.triangle_descriptors = None; + assert!(matches!( + query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ), + Err(MshError::MissingStream { + type_id: STREAM_TRIANGLE_DESCRIPTORS, + .. + }) + )); + + let mut model = segment_model(&[(0, 0)], &[0]); + model.node_stride = 24; + assert!(matches!( + query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ), + Err(MshError::InvalidGeometry(_)) + )); + + let mut model = segment_model(&[(0, 0)], &[0]); + model.slots[0].tri_start = 1; + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .is_err()); + + let mut model = segment_model(&[(0, 0)], &[0]); + model.slots[0].batch_count = 2; + assert!(matches!( + query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ), + Err(MshError::BatchRangeOutOfBounds { .. }) + )); + + let mut model = segment_model(&[(0, 0)], &[0]); + model.batches[0].index_count = 4; + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .is_err()); + + let mut model = segment_model(&[(0, 0)], &[0]); + model.batches[0].index_start = 3; + assert!(query_slot_segment( + &model, + NodeId(0), + SlotId(0), + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + SegmentQueryMasks::default(), + ) + .is_err()); } #[test] @@ -1535,8 +2295,10 @@ mod tests { normals: None, uv0: None, uv1: None, + type16_present: false, indices: Vec::new(), batches: Vec::new(), + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![ @@ -1596,8 +2358,10 @@ mod tests { normals: None, uv0: None, uv1: None, + type16_present: false, indices: Vec::new(), batches: Vec::new(), + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![ @@ -1660,8 +2424,10 @@ mod tests { normals: None, uv0: None, uv1: None, + type16_present: false, indices: Vec::new(), batches: Vec::new(), + triangle_descriptors: None, node_names: None, animation: Some(ModelAnimation { keys: vec![ @@ -2420,6 +3186,156 @@ mod tests { track.sample(AnimationTime(frame as f32)) } + #[test] + #[ignore = "requires licensed corpus"] + fn factory_wall_door_and_reversed_segments_match_native_query() { + let root = corpus_root("IS"); + let archive_path = files_under(&root) + .into_iter() + .find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("fortif.rlb")) + }) + .expect("fortif.rlb in licensed corpus"); + let archive_bytes = std::fs::read(&archive_path).expect("fortif.rlb bytes"); + let archive = fparkan_nres::decode( + Arc::from(archive_bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) + .expect("fortif.rlb archive"); + let entry = archive + .entries() + .iter() + .find(|entry| entry.name_bytes().eq_ignore_ascii_case(b"fr_l_plant.msh")) + .expect("plant factory MSH"); + let nested = fparkan_nres::decode( + Arc::from( + archive + .payload(entry.id()) + .expect("MSH payload") + .to_vec() + .into_boxed_slice(), + ), + ReadProfile::Compatible, + ) + .expect("nested plant MSH"); + let msh = decode_msh(&nested).expect("plant MSH document"); + let model = validate_msh(&msh).expect("plant MSH model"); + let slot = selected_slot(&model, NodeId(1), Lod(0), Group(0)).expect("Node 1 slot"); + assert_eq!(slot, SlotId(0)); + let hierarchy = node38_fallback_hierarchy(&model).expect("Node38 poses"); + let pose = hierarchy.poses[1]; + assert_eq!(pose.rotation, [0.0, 0.0, 0.0, 1.0]); + + let masks = SegmentQueryMasks { + node_include: 0, + node_exclude: 0, + batch_include: 0, + batch_exclude: 0x0208, + triangle_include: 0, + triangle_exclude: 0x0024, + }; + let local = |point: [f32; 3]| { + [ + point[0] - pose.translation[0], + point[1] - pose.translation[1], + point[2] - pose.translation[2], + ] + }; + let wall = query_slot_segment( + &model, + NodeId(1), + slot, + [local([-10.5, 22.16, 2.5]), local([-10.5, 19.16, 2.5])], + masks, + ) + .expect("wall query") + .expect("solid wall hit"); + assert_eq!(wall.batch_index, 5); + assert_eq!(wall.triangle_descriptor_index, 62); + assert_eq!(wall.distance_squared, 4.0); + assert!(query_slot_segment( + &model, + NodeId(1), + slot, + [local([0.5, 22.16, 2.5]), local([0.5, 19.16, 2.5])], + masks, + ) + .expect("door query") + .is_none()); + assert!(query_slot_segment( + &model, + NodeId(1), + slot, + [local([-10.5, 19.16, 2.5]), local([-10.5, 22.16, 2.5])], + masks, + ) + .expect("reversed wall query") + .is_none()); + } + + fn segment_model(batch_fields: &[(u16, u16)], tri_flags: &[u16]) -> ModelAsset { + assert_eq!(batch_fields.len(), tri_flags.len()); + let mut nodes_raw = node38([u16::MAX; 15]); + nodes_raw[0..2].copy_from_slice(&0x0004_u16.to_le_bytes()); + let mut positions = Vec::with_capacity(batch_fields.len() * 3); + let mut indices = Vec::with_capacity(batch_fields.len() * 3); + let mut batches = Vec::with_capacity(batch_fields.len()); + for index in 0..batch_fields.len() { + positions.extend_from_slice(&[[-1.0, 0.0, -1.0], [0.0, 0.0, 1.0], [1.0, 0.0, -1.0]]); + indices.extend_from_slice(&[0, 1, 2]); + batches.push(Batch { + batch_flags: batch_fields[index].0, + material_index_hi: batch_fields[index].1, + material_index: 0, + lightmap_index: 0, + local_batch_index: u16::try_from(index).expect("local batch index"), + index_count: 3, + index_start: u32::try_from(index * 3).expect("index start"), + vertex_count: 3, + base_vertex: u32::try_from(index * 3).expect("base vertex"), + }); + } + ModelAsset { + node_stride: 38, + node_count: 1, + nodes_raw, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 1.0, + slots: vec![Slot { + tri_start: 0, + tri_count: u16::try_from(tri_flags.len()).expect("triangle count"), + batch_start: 0, + batch_count: u16::try_from(batch_fields.len()).expect("batch count"), + aabb_min: [-1.0, 0.0, -1.0], + aabb_max: [1.0, 0.0, 1.0], + sphere_center: [0.0; 3], + sphere_radius: 1.0, + opaque: [0; 5], + }], + positions, + normals: None, + uv0: None, + uv1: None, + type16_present: false, + indices, + batches, + node_names: None, + animation: None, + triangle_descriptors: Some( + tri_flags + .iter() + .map(|tri_flags| TriangleDescriptor { + tri_flags: *tri_flags, + opaque: [0; 4], + normal: [0, i16::MAX, 0], + }) + .collect(), + ), + } + } + fn decode_nested(bytes: &[u8]) -> Result<NresDocument, NresError> { fparkan_nres::decode( Arc::from(bytes.to_vec().into_boxed_slice()), diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs index 08a285d..ad96204 100644 --- a/crates/fparkan-render/src/lib.rs +++ b/crates/fparkan-render/src/lib.rs @@ -361,6 +361,64 @@ pub enum LegacyCullMode { FrontFace, } +/// CPU diffuse alpha for a colourless MSH batch carrying flag `0x0008`. +/// +/// `packet_distance` is the distance to the packet's first accepted vertex, +/// after its object transform. Flag `0x0010` takes precedence over `0x0040`. +/// Returns `None` for invalid distance or projection inputs. Callers must keep +/// the material alpha for batches with a type-16 colour stream or without `0x0008`. +#[must_use] +pub fn legacy_msh_portal_alpha( + batch_flags: u16, + packet_distance: f32, + horizontal_fov: f32, +) -> Option<f32> { + if !packet_distance.is_finite() + || packet_distance < 0.0 + || !horizontal_fov.is_finite() + || horizontal_fov <= 0.0 + { + return None; + } + let (distance, near, far, slope) = if batch_flags & 0x0010 != 0 { + ( + packet_distance.sqrt(), + 2.6 / horizontal_fov, + 7.799_999_7 / horizontal_fov, + (1.0 / (7.799_999_7 - 2.6)) * horizontal_fov, + ) + } else if batch_flags & 0x0040 != 0 { + return Some(0.0); + } else { + let near = 75.0 * 1.3; + let far = 95.0 * 1.3; + ( + packet_distance, + horizontal_fov.recip() * near, + horizontal_fov.recip() * far, + (1.0 / (far - near)) * horizontal_fov, + ) + }; + Some(if distance < near { + 0.0 + } else if distance > far { + 1.0 + } else { + ((distance - near) * slope).clamp(0.0, 1.0) + }) +} + +/// Raster depth adjustment applied to one pipeline variant. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum LegacyDepthBias { + /// Preserve the interpolated depth exactly. + #[default] + Disabled, + /// Move projected decals toward the camera so they pass against their + /// coplanar receiver under a less-or-equal depth test. + ProjectedDecal, +} + /// Legacy fixed-function state that changes graphics-pipeline structure. /// /// Alpha reference is deliberately not present: it is dynamic material data, @@ -375,6 +433,8 @@ pub struct LegacyPipelineState { pub cull: LegacyCullMode, /// Whether alpha-test shader logic is enabled. pub alpha_test: bool, + /// Raster depth bias for projected decals. + pub depth_bias: LegacyDepthBias, } impl LegacyPipelineState { @@ -405,15 +465,16 @@ impl LegacyPipelineState { /// Canonical, backend-neutral key for a graphics-pipeline variant. /// /// The value is explicitly packed rather than hashed, so captures and caches -/// remain stable across processes and Rust toolchain updates. Three bits are -/// reserved for the five native material blend modes. +/// remain stable across processes and Rust toolchain updates. Bits 0..7 encode +/// blend, depth, cull and alpha-test state; bit 8 distinguishes projected +/// decal depth bias. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct PipelineKey(u8); +pub struct PipelineKey(u16); impl PipelineKey { /// Returns the canonical packed representation. #[must_use] - pub const fn packed(self) -> u8 { + pub const fn packed(self) -> u16 { self.0 } } @@ -437,7 +498,11 @@ impl From<LegacyPipelineState> for PipelineKey { LegacyCullMode::BackFace => 1, LegacyCullMode::FrontFace => 2, }; - Self(blend | (depth << 3) | (cull << 5) | (u8::from(state.alpha_test) << 7)) + let depth_bias = match state.depth_bias { + LegacyDepthBias::Disabled => 0, + LegacyDepthBias::ProjectedDecal => 1 << 8, + }; + Self(blend | (depth << 3) | (cull << 5) | (u16::from(state.alpha_test) << 7) | depth_bias) } } @@ -445,6 +510,40 @@ impl From<LegacyPipelineState> for PipelineKey { mod tests { use super::*; + #[test] + fn portal_alpha_matches_native_packet_distance_vectors() { + // GOG Terrain+1B0F0 with its real +246A0/+247B0 packet methods, + // Ngi32 RSQRTSS dispatch, x87 CW=007F, FOV=1.04. + for (distance, flags, expected) in [ + (0.499_938_96, 0x18, 0.0), + (4.999_542, 0x18, 0.0), + (9.999_084, 0x18, 0.132_426_6), + (25.000_572, 0x18, 0.500_011_44), + (59.999_084, 0x18, 1.0), + (85.012_48, 0x08, 0.0), + (109.994_125, 0x08, 0.649_765_2), + (25.000_572, 0x58, 0.500_011_44), + (110.0, 0x48, 0.0), + ] { + let actual = legacy_msh_portal_alpha(flags, distance, 1.04).unwrap(); + assert!( + (actual - expected).abs() < 0.000_002, + "{distance}: {actual}" + ); + } + assert_eq!(legacy_msh_portal_alpha(8, 60.0, 1.3), Some(0.0)); + assert!((legacy_msh_portal_alpha(8, 85.0, 1.3).unwrap() - 0.5).abs() < 0.000_001); + assert_eq!(legacy_msh_portal_alpha(8, 110.0, 1.3), Some(1.0)); + for (distance, fov) in [ + (f32::NAN, 1.3), + (-1.0, 1.3), + (1.0, 0.0), + (1.0, f32::INFINITY), + ] { + assert_eq!(legacy_msh_portal_alpha(8, distance, fov), None); + } + } + fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { let mut result = [0.0; 16]; for row in 0..4 { @@ -614,6 +713,24 @@ mod tests { } #[test] + fn projected_decal_bias_is_a_distinct_preserved_pipeline_state() { + let ordinary = LegacyPipelineState::default(); + let decal = LegacyPipelineState { + depth_bias: LegacyDepthBias::ProjectedDecal, + ..ordinary + }; + assert_eq!(ordinary.depth_bias, LegacyDepthBias::Disabled); + assert_ne!(PipelineKey::from(ordinary), PipelineKey::from(decal)); + assert_eq!( + decal + .with_material_category(1) + .expect("native category is represented") + .depth_bias, + LegacyDepthBias::ProjectedDecal + ); + } + + #[test] fn native_material_categories_have_distinct_pipeline_keys() { let base = LegacyPipelineState { depth: LegacyDepthMode::TestWrite, diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml index f66551b..80e5b78 100644 --- a/crates/fparkan-runtime/Cargo.toml +++ b/crates/fparkan-runtime/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true [dependencies] fparkan-assets = { path = "../fparkan-assets" } +fparkan-fx = { path = "../fparkan-fx" } fparkan-msh = { path = "../fparkan-msh" } fparkan-path = { path = "../fparkan-path" } fparkan-platform = { path = "../fparkan-platform" } @@ -17,3 +18,6 @@ fparkan-resource = { path = "../fparkan-resource" } fparkan-script = { path = "../fparkan-script" } fparkan-vfs = { path = "../fparkan-vfs" } fparkan-world = { path = "../fparkan-world" } + +[dev-dependencies] +fparkan-animation = { path = "../fparkan-animation" } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 5e352fc..5cc2d86 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -1,29 +1,33 @@ #![forbid(unsafe_code)] //! Runtime orchestration for mission loading and world stepping. -#[cfg(test)] -use fparkan_assets::PreparedControlResourcePair; +use fparkan_assets::ClanBody; use fparkan_assets::{ decode_mission_land_path, decode_mission_payload, decode_nres_payload, derive_mission_land_paths, extend_graph_report_with_visual_dependencies, mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError, AssetId, AssetManager, BehaviorProfileRates, BehaviorProfileVarError, BuildCategory, MissionAssetPlan, MissionDocument, MissionError, MissionTerrainPaths, NresError, - PreparedComponentControl, PreparedControlEffectSection, PreparedControlNodeDataRow, - PreparedControlNodeDataTable, PreparedControlTypedRecord, PreparedVisual, TerrainFormatError, - TerrainPreparationError, TerrainWorld, TmaProfile, + PreparedComponentControl, PreparedControlEffectRow, PreparedControlEffectSection, + PreparedControlNodeDataRow, PreparedControlNodeDataTable, PreparedControlResourcePair, + PreparedControlTypedRecord, PreparedVisual, ResearchError, ResearchTrfDocument, + TerrainFormatError, TerrainPreparationError, TerrainWorld, TmaProfile, }; +use fparkan_fx::{decode_fxid, FxOpcode, FXID_KIND}; use fparkan_msh::{node38_metadata, selected_slot, Group, Lod, NodeId}; use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; use fparkan_prototype::{ - build_prototype_graph_report, unit_component_tree, PrototypeGraph, PrototypeGraphFailure, - PrototypeGraphReport, UnitComponentRecord, UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO, - UNIT_DAT_BAS_PLACEMENT_FLAG, + build_prototype_graph_report, resolve_prototype_all, unit_component_tree, PrototypeGraph, + PrototypeGraphFailure, PrototypeGraphReport, PrototypeSource, UnitComponentRecord, + UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO, UNIT_DAT_BAS_PLACEMENT_FLAG, +}; +use fparkan_resource::{ + resource_name, CachedResourceRepository, ResourceError, ResourceKey, ResourceRepository, }; -use fparkan_resource::{resource_name, CachedResourceRepository, ResourceKey, ResourceRepository}; use fparkan_script::{ - Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError, - ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand, + FormulaTable, Handler19InitInput, ScriptInitResolution, ScriptInitResolveError, + ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, + VmHostCallbackCommand, }; use fparkan_terrain::SurfaceQuery; use fparkan_vfs::{Vfs, VfsError}; @@ -36,9 +40,13 @@ use std::collections::HashMap; use std::num::NonZeroUsize; use std::sync::Arc; +mod mesh_collision; + pub use fparkan_assets::MissionAssets; +pub use mesh_collision::{query_building_segment_world, BuildingMeshHit, BuildingMeshNode}; const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256; +const MAX_MISSION_AI_RECORD_SLOTS: usize = 1_000_000; const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var"; const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1; const UNIT_DAT_CONTROL_CLASS_HIGH_BIT: u32 = 0x8000_0000; @@ -82,6 +90,15 @@ pub struct MissionRequest { pub mode: MissionMode, } +/// Explicit inputs supplied by the startup caller to the full mission loader. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MissionStartupInput { + /// Caller-supplied queue clock used for the initial Behavior deadline. + pub queue_time_ms: u32, + /// Whether this startup creates the per-clan research tables. + pub research_creation_gate: bool, +} + const INITIAL_SCRIPT_HOST_STATUS: u32 = 5; /// Ordered mission property preserved for later runtime stages. @@ -190,6 +207,251 @@ pub struct ControlDeviceRecordProvenance { pub source: ResourceKey, } +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct Factory2StringPair { + archive: String, + name: String, +} + +impl Factory2StringPair { + fn empty() -> Self { + Self { + archive: String::new(), + name: String::new(), + } + } + + fn matches_ascii_case(&self, other: &Self) -> bool { + self.archive.eq_ignore_ascii_case(&other.archive) + && self.name.eq_ignore_ascii_case(&other.name) + } + + fn cache_key(&self) -> Self { + Self { + archive: self.archive.to_ascii_lowercase(), + name: self.name.to_ascii_lowercase(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Factory2PairOutcome { + Value(u32), + NativeMiss, + Unrepresentable, +} + +fn resolve_factory2_pair_from_assets<R: ResourceRepository>( + asset_manager: &AssetManager<R>, + vfs: &dyn Vfs, + pair: &Factory2StringPair, +) -> Result<Factory2PairOutcome, AssetPreparationError> { + if !pair.archive.eq_ignore_ascii_case("objects.rlb") + || pair.name.is_empty() + || pair + .name + .bytes() + .any(|byte| matches!(byte, b'/' | b'\\' | b'.')) + || normalize_relative(pair.name.as_bytes(), PathPolicy::StrictLegacy).is_err() + { + return Ok(Factory2PairOutcome::Unrepresentable); + } + + let root_name = resource_name(pair.name.as_bytes()); + let prototypes = + resolve_prototype_all(asset_manager.repository(), vfs, &root_name).map_err(|source| { + AssetPreparationError::InvalidPrototype(format!( + "factory-2 pair {}:{}: {source}", + pair.archive, pair.name + )) + })?; + let [prototype] = prototypes.as_slice() else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + if prototype.source != PrototypeSource::ObjectsRegistry + || prototype.source_type != Some(CONTROL_CLASS_SOURCE_BULL) + || !matches!( + prototype.geometry, + fparkan_prototype::PrototypeGeometry::Mesh(_) + ) + { + return Ok(Factory2PairOutcome::Unrepresentable); + } + + let assets = asset_manager.prepare_mission_assets(&[0..1], &prototypes)?; + let Some(controls) = assets.object_component_controls.first() else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + let [Some(control)] = controls.as_slice() else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + if !control.timeline_rows.is_empty() + || !control.typed_records.iter().all(|record| record.tag == 17) + || !matches!( + &control.effect_section, + PreparedControlEffectSection::Absent | PreparedControlEffectSection::Parsed(_) + ) + { + return Ok(Factory2PairOutcome::Unrepresentable); + } + let Some(node_data) = control.node_data.as_ref() else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + let Some(opcodes) = asset_manager.initial_control_effect_opcodes( + &control.source, + control.model_node_count, + 0, + )? + else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + if !opcodes.iter().all(|opcode| matches!(opcode, 1 | 4 | 12)) { + return Ok(Factory2PairOutcome::Unrepresentable); + } + if !factory2_selected_fx_resources_are_representable(asset_manager, control, &opcodes)? { + return Ok(Factory2PairOutcome::Unrepresentable); + } + match factory2_node_data_maximum_bits(&node_data.rows) { + Ok(bits) => Ok(Factory2PairOutcome::Value(bits)), + Err(_) => Ok(Factory2PairOutcome::Unrepresentable), + } +} + +fn factory2_selected_fx_resources_are_representable<R: ResourceRepository>( + asset_manager: &AssetManager<R>, + control: &PreparedComponentControl, + selected_opcodes: &[u32], +) -> Result<bool, AssetPreparationError> { + let selected_opcode4_count = selected_opcodes + .iter() + .filter(|opcode| **opcode == 4) + .count(); + let PreparedControlEffectSection::Parsed(rows) = &control.effect_section else { + return Ok(selected_opcode4_count == 0); + }; + let selected_rows = rows + .iter() + .filter(|row| Some(row.table_index) == control.initial_effect_group) + .collect::<Vec<_>>(); + if selected_rows.len() != selected_opcode4_count { + return Ok(false); + } + if selected_rows.is_empty() { + return Ok(true); + } + let Some(points) = control.point_list.as_ref() else { + return Ok(false); + }; + + for row in selected_rows { + if !row.point_offsets.iter().all(|offset| { + *offset != u32::MAX + && usize::try_from(*offset).is_ok_and(|index| index < points.points.len()) + }) { + return Ok(false); + } + let Some(key) = factory2_fx_resource_key(&row.resource) else { + return Ok(false); + }; + if !factory2_fx_resource_is_representable(asset_manager.repository(), &key, row)? { + return Ok(false); + } + } + Ok(true) +} + +fn factory2_fx_resource_key(resource: &PreparedControlResourcePair) -> Option<ResourceKey> { + let archive = bounded_ascii_c_string(&resource.archive)?; + let name = bounded_ascii_c_string(&resource.name)?; + if archive.is_empty() || name.is_empty() { + return None; + } + Some(ResourceKey { + archive: normalize_relative(archive.as_bytes(), PathPolicy::StrictLegacy).ok()?, + name: resource_name(name.as_bytes()), + type_id: Some(FXID_KIND), + }) +} + +fn factory2_fx_resource_is_representable<R: ResourceRepository>( + repository: &R, + key: &ResourceKey, + row: &PreparedControlEffectRow, +) -> Result<bool, AssetPreparationError> { + let context = format!( + "factory-2 selected Control effect group {} row {} FX {}:{}", + row.table_index, + row.row_index, + key.archive.as_str(), + String::from_utf8_lossy(&key.name.0) + ); + let archive = match repository.open_archive_unchanged(&key.archive) { + Ok(archive) => archive, + Err(ResourceError::MissingArchive { .. } | ResourceError::MissingEntry) => { + return Ok(false); + } + Err(source) => { + return Err(AssetPreparationError::Resource { + context, + source: Box::new(source), + }); + } + }; + let entry = + repository + .find(archive, &key.name) + .map_err(|source| AssetPreparationError::Resource { + context: context.clone(), + source: Box::new(source), + })?; + let Some(entry) = entry else { + return Ok(false); + }; + let info = repository + .entry_info(entry) + .map_err(|source| AssetPreparationError::Resource { + context: context.clone(), + source: Box::new(source), + })?; + if info.key.type_id != Some(FXID_KIND) { + return Ok(false); + } + let bytes = repository + .read(entry) + .map_err(|source| AssetPreparationError::Resource { + context: context.clone(), + source: Box::new(source), + })?; + let document = decode_fxid(Arc::from(bytes.into_owned())).map_err(|source| { + AssetPreparationError::InvalidPrototype(format!("{context}: {source}")) + })?; + Ok(document.commands().iter().all(|command| { + matches!( + command.opcode, + FxOpcode::Op2 + | FxOpcode::Op3 + | FxOpcode::Op4 + | FxOpcode::Op5 + | FxOpcode::Op7 + | FxOpcode::Op8 + | FxOpcode::Op9 + ) + })) +} + +fn factory2_node_data_maximum_bits( + rows: &[PreparedControlNodeDataRow], +) -> Result<u32, &'static str> { + let mut maximum = 0.0_f32; + for row in rows { + let value = checked_control_float(2, "factory-2 NDP Max", row.max_volume) + .map_err(|_| "NDP Max float")?; + maximum = checked_control_sum(2, "factory-2 NDP Max sum", maximum, value) + .map_err(|_| "NDP Max sum")?; + } + Ok(maximum.to_bits()) +} + /// Common fields known for one row in a host object's Control device table. /// /// The row index is its position in the containing `Vec`. This stores the @@ -210,6 +472,8 @@ pub struct EffectiveControlDevice { pub class9_selector0_scale_bits: Option<u32>, /// Factory-26 mutable resource-rate addition at native `+0xa4`. pub tag26_extra_rate_bits: Option<u32>, + /// Factory-2 value read by selector 6 at native `+0x174`. + pub device174_bits: Option<u32>, /// Six class-9 budget values at native `+0x98..+0xac`. pub class9_values_bits: Option<[u32; 6]>, /// Six accumulated class-9 changes at native `+0xb0..+0xc4`. @@ -481,6 +745,38 @@ pub struct MissionControlDeviceTableState { pub current_output_rate_5e0_bits: u32, } +/// One initial outer row prepared from the current Control descriptor table. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionBehaviorControlGroup { + /// Table ordinal of the descriptor whose current tag starts this group. + pub header_device_ordinal: usize, + /// Table ordinals of current-tag-2 descriptors following this header. + pub candidate_device_ordinals: Vec<usize>, + /// Table ordinals of current-tag-30 descriptors following this header. + pub tag30_device_ordinals: Vec<usize>, +} + +/// Initial grouping of a prepared Control table, before live Behavior refreshes. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MissionBehaviorControlGrouping { + /// Groups in the order their current-tag-1 headers appeared. + pub groups: Vec<MissionBehaviorControlGroup>, + /// Current-tag-2 entries before the first header. + pub ungrouped_candidate_device_ordinals: Vec<usize>, +} + +/// Initial Behavior grouping projection for one full-load mission object. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionBehaviorControlGroupingState { + /// Stable identity from the source TMA object order. + pub original_id: Option<OriginalObjectId>, + /// Corresponding live Rust world object. + pub handle: ObjectHandle, + /// Initial grouping, or the reason its prepared Control table is unavailable. + pub grouping: + Result<MissionBehaviorControlGrouping, MissionControlDeviceTableUnavailableReason>, +} + /// Provenance for the class passed to Control's constructor. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MissionControlClassSource { @@ -654,9 +950,11 @@ pub struct MissionScriptBundle { pub base_path: String, /// Resolved compiled `.scr` path. pub script_path: String, - /// Losslessly decoded compiled package; only proven `Handler(19)` Init - /// writes are resolved and retained by runtime. + /// Losslessly decoded compiled package; only the recovered first-`Init` + /// subset is resolved and retained by runtime. pub package: ScriptPackage, + /// Optional formula rows loaded from this bundle's sibling `.fml` file. + pub formula_table: Option<FormulaTable>, /// Outcome of the restricted first-`Init` resolution for this bundle. pub init_execution: MissionScriptInitExecution, /// Recovered per-AI Behavior scheduler and total state. @@ -664,6 +962,26 @@ pub struct MissionScriptBundle { pub behavior_warm_start: Option<BehaviorWarmStartState>, } +/// Initial native record-slot words owned by one mission clan's AI instance. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionAiStartupState { + /// TMA clan index that owns this AI instance. + pub clan_index: usize, + /// Sentinel words appended during AI startup, in native insertion order. + pub record_slots: Vec<u32>, +} + +/// One decoded research table associated with its TMA clan owner and source path. +#[derive(Debug)] +pub struct MissionResearchTrfTable { + /// TMA clan index that owns this table. + pub clan_index: usize, + /// Resolved TMA second-resource path used to load this table. + pub path: String, + /// Independently decoded TRF document. + pub document: ResearchTrfDocument, +} + /// Behavior distributor clock and totals tracked from warm start through resource ticks. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct BehaviorWarmStartState { @@ -722,11 +1040,12 @@ fn schedule_behavior_distributor_tick( Some(behavior_elapsed_seconds(elapsed_ms)) } -/// Numeric script defaults selected for the mission's first script bundle. +/// Numeric script declarations selected for the mission's first script bundle. /// /// GOG `ai.dll` first tries `<bundle-base>.var`, then falls back to the shared /// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source -/// and supplies the typed targets for the proven `Handler(19)` Init writes. +/// and supplies typed targets for recovered `Handler(19)` and numeric formula +/// assignments in the first `Init` event. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionScriptVarSet { /// Resolved `.var` path selected by the original fallback order. @@ -737,11 +1056,7 @@ pub struct MissionScriptVarSet { pub declarations: VarSet, } -/// One resolved, corpus-proven `Init` result for a mission clan. -/// -/// This is intentionally limited to `Handler(19)`: an `Init` event's other -/// selectors are retained in [`MissionScriptBundle::package`] but are not -/// guessed as executable behavior. +/// One resolved subset of the first `Init` event for a mission clan. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionScriptInitState { /// TMA clan index that owns the `SuperAI` instance and varset. @@ -750,8 +1065,8 @@ pub struct MissionScriptInitState { pub event_index: usize, /// Raw event word preserved from the compiled `Init` event. pub event_word: u32, - /// Exact three DWORD writes made by each proven `Handler(19)` instruction. - pub writes: Vec<Handler19DwordWrite>, + /// Ordered numeric writes made by the supported `Init` instructions. + pub writes: Vec<ScriptInitWrite>, } /// Execution status of the first event whose case-sensitive C-string name is `Init`. @@ -765,14 +1080,14 @@ pub enum MissionScriptInitExecution { event_index: usize, }, /// A preview retained the decoded package but stopped before the first - /// instruction outside the recovered `Handler(19)` action subset. + /// instruction outside the recovered numeric `Init` subset. Unsupported { /// Zero-based event table index selected by native lookup. event_index: usize, /// Zero-based instruction index where restricted resolution stopped. instruction_index: usize, - /// Typed selector, control, or result-save shape that is not modeled. - error: Handler19ResolveError, + /// Typed selector, operand, or expression shape that is not modeled. + error: ScriptInitResolveError, }, } @@ -823,6 +1138,7 @@ struct MissionLoadOptions { #[derive(Clone, Debug, Default, Eq, PartialEq)] struct LoadedMissionScripts { bundles: Vec<MissionScriptBundle>, + ai_startup_states: Vec<MissionAiStartupState>, varset: Option<MissionScriptVarSet>, init_states: Vec<MissionScriptInitState>, varset_states: Vec<MissionScriptVarSetState>, @@ -915,7 +1231,7 @@ pub struct LoadedMission { pub script_event_count: usize, /// Numeric script defaults loaded through the original bundle/fallback rule. pub script_varset_declaration_count: usize, - /// `Handler(19)` Init results resolved for mission clans. + /// Writes resolved for the first mission-clan `Init` events. pub script_init_state_count: usize, /// Instantiated per-clan varsets whose Init writes have been applied. pub script_varset_state_count: usize, @@ -950,6 +1266,9 @@ struct LoadedMissionState { behavior_ore_states: Vec<BehaviorOreState>, behavior_profiles: Vec<BehaviorProfileState>, control_device_tables: Vec<MissionControlDeviceTableState>, + behavior_control_groupings: Vec<MissionBehaviorControlGroupingState>, + research_trf_tables: Vec<MissionResearchTrfTable>, + ai_startup_states: Vec<MissionAiStartupState>, script_bundles: Vec<MissionScriptBundle>, script_varset: Option<MissionScriptVarSet>, script_init_states: Vec<MissionScriptInitState>, @@ -994,7 +1313,7 @@ pub enum EngineError { /// Parse diagnostic. message: String, }, - /// A recovered `Handler(19)` Init input or target was not representable. + /// A recovered `Init` input or write target was not representable. ScriptInit { /// Owning TMA clan index. clan_index: usize, @@ -1012,7 +1331,7 @@ pub enum EngineError { /// Zero-based instruction index within that event. instruction_index: usize, /// Typed unsupported instruction shape. - source: Handler19ResolveError, + source: ScriptInitResolveError, }, /// A recovered script handler could not resolve against runtime state. ScriptRuntime { @@ -1021,6 +1340,17 @@ pub enum EngineError { /// Handler-specific diagnostic. message: String, }, + /// A clan's initial AI record slots exceeded the Rust allocation budget or could not be reserved. + MissionAiRecordSlots { + /// Owning TMA clan index. + clan_index: usize, + /// Raw second-resource tag interpreted as unsigned bits. + raw_count: u32, + /// Slots already reserved for earlier clans in this mission. + allocated_slots: usize, + /// Budget or allocation diagnostic. + message: String, + }, /// `NRes` decode error. Nres { /// Resource path. @@ -1028,6 +1358,22 @@ pub enum EngineError { /// Source error. source: NresError, }, + /// A loaded clan's second TMA resource could not be decoded as a TRF table. + ResearchTrf { + /// Owning TMA clan index. + clan_index: usize, + /// Resolved resource path. + path: String, + /// TRF decode diagnostic. + source: ResearchError, + }, + /// A clan's research path is not representable by the supported ASCII path layer. + ResearchTrfPath { + /// Owning TMA clan index. + clan_index: usize, + /// Raw C-string bytes before path normalization. + path: Vec<u8>, + }, /// Mission decode error. Mission { /// Resource path. @@ -1184,7 +1530,25 @@ impl std::fmt::Display for EngineError { clan_index, message, } => write!(f, "clan {clan_index}: script runtime failed: {message}"), + Self::MissionAiRecordSlots { + clan_index, + raw_count, + allocated_slots, + message, + } => write!( + f, + "clan {clan_index} AI initial slots (unsigned raw count {raw_count}, after {allocated_slots} mission slots): {message}" + ), Self::Nres { path, source } => write!(f, "{path}: {source}"), + Self::ResearchTrf { + clan_index, + path, + source, + } => write!(f, "clan {clan_index} research TRF {path}: {source}"), + Self::ResearchTrfPath { clan_index, path } => write!( + f, + "clan {clan_index} research TRF path contains non-ASCII bytes: {path:?}" + ), Self::Mission { path, source } => write!(f, "{path}: {source}"), Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"), Self::Terrain(source) => write!(f, "{source}"), @@ -1223,6 +1587,7 @@ impl std::error::Error for EngineError { Self::Path { source, .. } => Some(source), Self::Vfs { source, .. } => Some(source), Self::Nres { source, .. } => Some(source), + Self::ResearchTrf { source, .. } => Some(source), Self::Mission { source, .. } => Some(source), Self::TerrainFormat { source, .. } => Some(source), Self::ScriptInitUnsupported { source, .. } => Some(source), @@ -1234,7 +1599,9 @@ impl std::error::Error for EngineError { | Self::VarSet { .. } | Self::ScriptInit { .. } | Self::ScriptRuntime { .. } + | Self::MissionAiRecordSlots { .. } | Self::Movement(_) + | Self::ResearchTrfPath { .. } | Self::PrototypeGraph { .. } | Self::BehaviorProfile { .. } | Self::BehaviorDistributorTick { .. } @@ -1255,10 +1622,12 @@ pub fn create(services: EngineServices) -> Result<Engine, EngineError> { /// Loads a mission transactionally and runs the recovered initial script subset. /// -/// `queue_time_ms` is the caller-supplied 32-bit queue-clock sample used to -/// initialize the first Behavior deadline for each script bundle. This runtime -/// entry point does not acquire the native queue clock or reproduce the full -/// SuperAI host lifecycle. +/// `startup_input.queue_time_ms` is the caller-supplied 32-bit queue-clock +/// sample used to initialize the first Behavior deadline for each script bundle. +/// `startup_input.research_creation_gate` explicitly controls whether full +/// startup loads per-clan research tables; it is not inferred from mission mode +/// or path. This runtime entry point does not acquire the native queue clock or +/// reproduce the full SuperAI host lifecycle. /// /// # Errors /// @@ -1270,13 +1639,13 @@ pub fn create(services: EngineServices) -> Result<Engine, EngineError> { pub fn load_mission( engine: &mut Engine, request: MissionRequest, - queue_time_ms: u32, + startup_input: MissionStartupInput, ) -> Result<LoadedMission, EngineError> { load_mission_with_options( engine, request, MissionLoadOptions::default(), - Some(queue_time_ms), + Some(startup_input), ) } @@ -1335,7 +1704,7 @@ fn load_mission_with_options( engine: &mut Engine, request: MissionRequest, options: MissionLoadOptions, - queue_time_ms: Option<u32>, + startup_input: Option<MissionStartupInput>, ) -> Result<LoadedMission, EngineError> { let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?; let mission_path = normalize_engine_path("mission", &request.key)?; @@ -1387,6 +1756,13 @@ fn load_mission_with_options( } })?; let mut loaded_scripts = load_mission_scripts(&vfs, &mission, options.asset_scope)?; + let research_trf_tables = if options.asset_scope == MissionAssetScope::Full + && startup_input.is_some_and(|input| input.research_creation_gate) + { + load_mission_research_trf_tables(&vfs, &mission, &loaded_scripts.ai_startup_states)? + } else { + Vec::new() + }; let repository = CachedResourceRepository::with_payload_cache_budget( vfs.clone(), MISSION_DECODED_PAYLOAD_CACHE_ENTRIES, @@ -1595,7 +1971,9 @@ fn load_mission_with_options( Vec::new() }; + let mut first_factory2_asset_error = None; let control_device_tables = if options.asset_scope == MissionAssetScope::Full { + let mut factory2_pair_cache = HashMap::new(); object_drafts .iter() .zip(&handles) @@ -1609,6 +1987,29 @@ fn load_mission_with_options( prototype_span, &resolved_prototypes, ); + let mut resolve_factory2_pair = |pair: &Factory2StringPair| { + if first_factory2_asset_error.is_some() { + return Factory2PairOutcome::Unrepresentable; + } + let cache_key = pair.cache_key(); + if let Some(outcome) = factory2_pair_cache.get(&cache_key) { + return *outcome; + } + match resolve_factory2_pair_from_assets( + &asset_manager, + vfs.as_ref(), + pair, + ) { + Ok(outcome) => { + factory2_pair_cache.insert(cache_key, outcome); + outcome + } + Err(error) => { + first_factory2_asset_error = Some(error); + Factory2PairOutcome::Unrepresentable + } + } + }; let projection = build_control_device_projection_with_class( object_index, draft, @@ -1616,6 +2017,7 @@ fn load_mission_with_options( prototype_span, &resolved_prototypes, native_control_class, + &mut resolve_factory2_pair, ); match projection { Ok(projection) => MissionControlDeviceTableState { @@ -1659,10 +2061,27 @@ fn load_mission_with_options( } else { Vec::new() }; + if let Some(source) = first_factory2_asset_error { + let _ = fparkan_world::shutdown(new_runtime_world); + return Err(EngineError::AssetPreparation { + mission: request.key.clone(), + source, + }); + } + let behavior_control_groupings = if options.asset_scope == MissionAssetScope::Full { + control_device_tables + .iter() + .map(project_mission_behavior_control_grouping) + .collect() + } else { + Vec::new() + }; let mut next_behavior_rng_state = engine.behavior_rng_state; if options.asset_scope == MissionAssetScope::Full { - let queue_time_ms = queue_time_ms.expect("full mission loading requires a queue time"); + let queue_time_ms = startup_input + .expect("full mission loading requires startup inputs") + .queue_time_ms; if let Err(error) = start_mission_scripts( &mission, &mut loaded_scripts, @@ -1738,6 +2157,9 @@ fn load_mission_with_options( behavior_ore_states, behavior_profiles, control_device_tables, + behavior_control_groupings, + research_trf_tables, + ai_startup_states: loaded_scripts.ai_startup_states, script_bundles: loaded_scripts.bundles, script_varset: loaded_scripts.varset, script_init_states: loaded_scripts.init_states, @@ -1819,6 +2241,24 @@ pub fn loaded_mission_script_bundles(engine: &Engine) -> Option<&[MissionScriptB .map(|state| state.script_bundles.as_slice()) } +/// Returns the per-clan AI record slots initialized during full mission startup. +#[must_use] +pub fn loaded_mission_ai_startup_states(engine: &Engine) -> Option<&[MissionAiStartupState]> { + engine + .loaded + .as_ref() + .map(|state| state.ai_startup_states.as_slice()) +} + +/// Returns the per-clan TRF tables loaded during full mission startup. +#[must_use] +pub fn loaded_mission_research_trf_tables(engine: &Engine) -> Option<&[MissionResearchTrfTable]> { + engine + .loaded + .as_ref() + .map(|state| state.research_trf_tables.as_slice()) +} + /// Returns the numeric script defaults selected by the original `.var` fallback rule. #[must_use] pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVarSet> { @@ -1828,10 +2268,11 @@ pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVar .and_then(|state| state.script_varset.as_ref()) } -/// Returns the proven script `Init` writes resolved for the loaded mission. +/// Returns the recovered script `Init` writes resolved for the loaded mission. /// -/// The list has one entry for each clan whose `Init` event contains recovered -/// `Handler(19)` instructions. It is not a general script event executor. +/// The list has one entry for each clan whose first `Init` event contains a +/// supported `Handler(19)` instruction or numeric FML literal assignment. It +/// is not a general script event executor. #[must_use] pub fn loaded_mission_script_init_states(engine: &Engine) -> Option<&[MissionScriptInitState]> { engine @@ -2058,9 +2499,65 @@ pub fn loaded_mission_control_device_tables( .map(|state| state.control_device_tables.as_slice()) } -/// Queries native Control global-table selector 1 (weighted ratio) or 2 (sum). +/// Returns the initial prepared Control grouping for full-load mission objects. +#[must_use] +pub fn loaded_mission_behavior_control_groupings( + engine: &Engine, +) -> Option<&[MissionBehaviorControlGroupingState]> { + engine + .loaded + .as_ref() + .map(|state| state.behavior_control_groupings.as_slice()) +} + +fn project_mission_behavior_control_grouping( + table: &MissionControlDeviceTableState, +) -> MissionBehaviorControlGroupingState { + let grouping = match &table.status { + MissionControlDeviceTableStatus::Available(devices) => { + Ok(build_mission_behavior_control_grouping(devices)) + } + MissionControlDeviceTableStatus::Unavailable(reason) => Err(reason.clone()), + }; + MissionBehaviorControlGroupingState { + original_id: table.original_id, + handle: table.handle, + grouping, + } +} + +fn build_mission_behavior_control_grouping( + devices: &[EffectiveControlDevice], +) -> MissionBehaviorControlGrouping { + let mut grouping = MissionBehaviorControlGrouping::default(); + for (ordinal, device) in devices.iter().enumerate() { + match device.descriptor.tag { + 1 => { + grouping.groups.push(MissionBehaviorControlGroup { + header_device_ordinal: ordinal, + candidate_device_ordinals: Vec::new(), + tag30_device_ordinals: Vec::new(), + }); + } + 2 => match grouping.groups.last_mut() { + Some(group) => group.candidate_device_ordinals.push(ordinal), + None => grouping.ungrouped_candidate_device_ordinals.push(ordinal), + }, + 30 => { + if let Some(group) = grouping.groups.last_mut() { + group.tag30_device_ordinals.push(ordinal); + } + } + _ => {} + } + } + grouping +} + +/// Queries native Control global-table selector 1 (weighted ratio), 2 (sum), or 15. /// `Ok(None)` preserves the original false/no-result behavior for an empty or -/// zero-denominator table. Other unsupported native paths are reported as errors. +/// zero-denominator table or selector 15 without both cached factory rows. Other +/// unsupported native paths are reported as errors. /// Inputs and intermediates must be finite and normal, or exact zero. /// /// # Errors @@ -2084,20 +2581,27 @@ pub fn query_loaded_mission_control_resource( query_control_device_table_resource(table, selector) } -/// Queries one loaded Control device through its original factory getter. +/// Queries one loaded Control device through its indexed selector interface. /// -/// Selector `0xe00` is modeled for current descriptor tags 5 and 26 with -/// factory tags 5 and 26. Both common factory readers use the device key to -/// read the current provider value and multiply it by coefficient 0. Only -/// current tag 26 also multiplies by `M`; current tag 5 ignores `M` and -/// descriptor word 8 for this selector. Explicit Control update calls can -/// change current provider or multiplier values; the mission clock does not -/// advance them automatically. +/// Selector `0x400` returns the current type-1 value from the provider row +/// selected by the device key, including signed zero, regardless of current +/// descriptor or factory tag. Selector `0xe00` is modeled for current +/// descriptor tags 5 and 26 with factory tags 5 and 26. Both common factory +/// readers use the device key to read the current provider value and multiply +/// it by coefficient 0. Only current tag 26 also multiplies by `M`; current +/// tag 5 ignores `M` and descriptor word 8 for this selector. Explicit Control +/// update calls can change current provider or multiplier values; the mission +/// clock does not advance them automatically. Selector `6` first checks the +/// current descriptor tag: a non-2 tag returns `Ok(None)`. For the represented +/// factory-2 layout it reads `+0x174`, subject to the normal-or-zero float +/// range checks; an unsupported field or layout returns a typed error. This is +/// a query of the loaded CPU projection, not an FX execution or game tick. /// /// # Errors /// Returns an error for an invalid handle, unavailable mission/table/provider, -/// an out-of-range device/provider key, or a factory/tag/float path that is not -/// supported by the current reconstruction. +/// an out-of-range device/provider key, an unsupported float range, or an +/// unsupported current/factory tag on selector `0xe00`, or an unsupported +/// selector-6 factory field/layout. pub fn query_loaded_mission_control_device_selector( engine: &Engine, handle: ObjectHandle, @@ -4127,7 +4631,7 @@ fn query_control_device_table_resource( table: &MissionControlDeviceTableState, selector: u32, ) -> Result<Option<f32>, ControlResourceAccessError> { - if !matches!(selector, 1 | 2) { + if !matches!(selector, 1 | 2 | 15) { return Err(ControlResourceAccessError::UnsupportedSelector(selector)); } let entries = match &table.status { @@ -4137,6 +4641,32 @@ fn query_control_device_table_resource( } }; + if selector == 15 { + let Some(class9) = entries.iter().rev().find(|entry| entry.factory_tag == 9) else { + return Ok(None); + }; + let Some(class21) = entries.iter().rev().find(|entry| entry.factory_tag == 21) else { + return Ok(None); + }; + + let mut sum = control_device_budget_coefficient(class21, 5)?; + for index in (0..5).rev() { + sum = checked_control_sum( + 21, + "sum", + sum, + control_device_budget_coefficient(class21, index)?, + )?; + } + return checked_control_product( + 9, + "selector-15 value", + control_device_selector_zero(class9)?, + sum, + ) + .map(Some); + } + let mut numerator = 0.0_f32; let mut denominator = 0.0_f32; for entry in entries.iter().filter(|entry| entry.descriptor.tag == 19) { @@ -4173,7 +4703,7 @@ fn query_control_device_table_selector( device_index: usize, selector: u32, ) -> Result<Option<f32>, ControlResourceAccessError> { - if selector != 0xe00 { + if !matches!(selector, 6 | 0x400 | 0xe00) { return Err(ControlResourceAccessError::UnsupportedSelector(selector)); } let devices = match &table.status { @@ -4189,6 +4719,21 @@ fn query_control_device_table_selector( device_index, device_count: devices.len(), })?; + if selector == 0x400 { + return control_device_provider_value_from_status(&table.node_provider_status, device) + .map(Some); + } + if selector == 6 { + if device.descriptor.tag != 2 { + return Ok(None); + } + if device.factory_tag != 2 { + return Err(ControlResourceAccessError::UnsupportedFactoryTag { + factory_tag: device.factory_tag, + }); + } + return control_device_float(device, device.device174_bits, "+0x174").map(Some); + } query_control_device_selector(device, &table.node_provider_status) } @@ -5547,7 +6092,7 @@ fn append_effective_control_device( } else { Some(descriptor.fixed_words[6]) }; - devices.push(EffectiveControlDevice { + let device = EffectiveControlDevice { factory_tag: descriptor.tag, component_id, key, @@ -5555,6 +6100,7 @@ fn append_effective_control_device( multiplier_bits, class9_selector0_scale_bits: (descriptor.tag == 9).then_some(0x3f80_0000), tag26_extra_rate_bits: (descriptor.tag == 26).then_some(0), + device174_bits: (descriptor.tag == 2).then_some(0), class9_values_bits: (descriptor.tag == 9).then_some([0x3f80_0000; 6]), class9_deltas_bits: (descriptor.tag == 9).then_some([0; 6]), native_word_50_bits, @@ -5562,7 +6108,65 @@ fn append_effective_control_device( descriptor: descriptor.clone(), factory_provenance: provenance.clone(), descriptor_provenance: provenance, - }); + }; + devices.push(device); +} + +fn bounded_ascii_c_string(raw: &[u8]) -> Option<String> { + let end = raw.iter().position(|byte| *byte == 0)?; + let bytes = &raw[..end]; + if !bytes.is_ascii() { + return None; + } + std::str::from_utf8(bytes).ok().map(str::to_owned) +} + +fn fixed_words_ascii_c_string(words: &[u32]) -> Option<String> { + let bytes = words + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect::<Vec<_>>(); + bounded_ascii_c_string(&bytes) +} + +fn factory2_pair_from_record( + descriptor: &PreparedControlTypedRecord, +) -> Option<Factory2StringPair> { + Some(Factory2StringPair { + archive: fixed_words_ascii_c_string(&descriptor.fixed_words[27..35])?, + name: fixed_words_ascii_c_string(&descriptor.fixed_words[35..43])?, + }) +} + +fn apply_factory2_pair_update( + device: &mut EffectiveControlDevice, + previous_pair: Option<Factory2StringPair>, + descriptor: &PreparedControlTypedRecord, + resolve_factory2_pair: &mut dyn FnMut(&Factory2StringPair) -> Factory2PairOutcome, +) { + if device.factory_tag != 2 { + return; + } + let Some(pair) = factory2_pair_from_record(descriptor) else { + device.device174_bits = None; + return; + }; + if previous_pair + .as_ref() + .is_some_and(|previous| previous.matches_ascii_case(&pair)) + { + return; + } + let outcome = if pair.archive.is_empty() || pair.name.is_empty() { + Factory2PairOutcome::NativeMiss + } else { + resolve_factory2_pair(&pair) + }; + match outcome { + Factory2PairOutcome::Value(bits) => device.device174_bits = Some(bits), + Factory2PairOutcome::NativeMiss => {} + Factory2PairOutcome::Unrepresentable => device.device174_bits = None, + } } fn apply_effective_control_override( @@ -5655,6 +6259,7 @@ fn build_control_device_projection( ) -> Result<ControlDeviceProjection, MissionControlDeviceTableUnavailableReason> { let native_class = native_control_class_for_root(draft.unit_dat_header_flags, prototype_span, prototypes); + let mut resolve_factory2_pair = |_: &Factory2StringPair| Factory2PairOutcome::Unrepresentable; build_control_device_projection_with_class( object_index, draft, @@ -5662,6 +6267,7 @@ fn build_control_device_projection( prototype_span, prototypes, native_class, + &mut resolve_factory2_pair, ) } @@ -5672,6 +6278,7 @@ fn build_control_device_projection_with_class( prototype_span: Option<&std::ops::Range<usize>>, prototypes: &[fparkan_prototype::EffectivePrototype], native_control_class: Option<MissionControlClassProjection>, + resolve_factory2_pair: &mut dyn FnMut(&Factory2StringPair) -> Factory2PairOutcome, ) -> Result<ControlDeviceProjection, MissionControlDeviceTableUnavailableReason> { use MissionControlDeviceTableUnavailableReason as Unavailable; @@ -5980,6 +6587,12 @@ fn build_control_device_projection_with_class( control, descriptor, ); + apply_factory2_pair_update( + devices.last_mut().expect("append inserted one device"), + Some(Factory2StringPair::empty()), + descriptor, + resolve_factory2_pair, + ); initialize_control_profile_cache27(&mut profile_cache27_bits, descriptor); } continue; @@ -6020,6 +6633,12 @@ fn build_control_device_projection_with_class( control, descriptor, ); + apply_factory2_pair_update( + devices.last_mut().expect("append inserted one device"), + Some(Factory2StringPair::empty()), + descriptor, + resolve_factory2_pair, + ); initialize_control_profile_cache27(&mut profile_cache27_bits, descriptor); } } @@ -6048,6 +6667,13 @@ fn build_control_device_projection_with_class( factory_tag: device.factory_tag, }); } + let previous_pair = factory2_pair_from_record(&device.descriptor); + apply_factory2_pair_update( + device, + previous_pair, + descriptor, + resolve_factory2_pair, + ); apply_effective_control_override(device, descriptor, component_index, 0, control); update_control_profile_cache27_on_into(&mut profile_cache27_bits, descriptor); } @@ -6332,6 +6958,91 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPa }) } +fn script_bundle_stem(base_path: &str) -> &str { + let suffix_start = base_path.len().saturating_sub(4); + if base_path + .get(suffix_start..) + .is_some_and(|suffix| suffix.eq_ignore_ascii_case(".scr")) + { + &base_path[..suffix_start] + } else { + base_path + } +} + +fn script_sibling_path(base_path: &str, extension: &str) -> String { + format!("{}.{extension}", script_bundle_stem(base_path)) +} + +fn load_script_formula_table( + vfs: &Arc<dyn Vfs>, + base_path: &str, +) -> Result<Option<FormulaTable>, EngineError> { + let path = normalize_engine_path("mission formula", &script_sibling_path(base_path, "fml"))?; + match vfs.read(&path) { + Ok(bytes) => Ok(Some(FormulaTable::parse(&bytes))), + Err(VfsError::NotFound(_)) => Ok(None), + Err(source) => Err(EngineError::Vfs { + path: path.as_str().to_string(), + source, + }), + } +} + +fn load_mission_research_trf_tables( + vfs: &Arc<dyn Vfs>, + mission: &MissionDocument, + ai_startup_states: &[MissionAiStartupState], +) -> Result<Vec<MissionResearchTrfTable>, EngineError> { + let mut tables = Vec::with_capacity(ai_startup_states.len()); + for ai_state in ai_startup_states { + let Some(clan) = mission.clans.get(ai_state.clan_index) else { + return Err(EngineError::ScriptRuntime { + clan_index: ai_state.clan_index, + message: "AI startup state clan index is outside the decoded mission".to_string(), + }); + }; + let second_resource = match &clan.body { + ClanBody::Standard { + second_resource, .. + } + | ClanBody::Spatial { + second_resource, .. + } => second_resource, + }; + let raw_path = second_resource + .path + .raw + .split(|byte| *byte == 0) + .next() + .unwrap_or_default(); + if raw_path.is_empty() { + continue; + } + if !raw_path.is_ascii() { + return Err(EngineError::ResearchTrfPath { + clan_index: ai_state.clan_index, + path: raw_path.to_vec(), + }); + } + let value = std::str::from_utf8(raw_path).expect("ASCII paths are valid UTF-8"); + let path = normalize_engine_path("mission research TRF", value)?; + let bytes = read_vfs(vfs, &path)?; + let document = + ResearchTrfDocument::decode(bytes).map_err(|source| EngineError::ResearchTrf { + clan_index: ai_state.clan_index, + path: path.as_str().to_string(), + source, + })?; + tables.push(MissionResearchTrfTable { + clan_index: ai_state.clan_index, + path: path.as_str().to_string(), + document, + }); + } + Ok(tables) +} + fn load_mission_scripts( vfs: &Arc<dyn Vfs>, mission: &MissionDocument, @@ -6340,6 +7051,54 @@ fn load_mission_scripts( let script_bases = mission_script_bundle_bases(mission); let mut bundles = Vec::with_capacity(script_bases.len()); let mut first_base_path = None; + let mut ai_startup_states = Vec::new(); + if asset_scope == MissionAssetScope::Full { + let mut allocated_slots = 0usize; + for (clan_index, clan) in mission.clans.iter().enumerate() { + let second_resource = match &clan.body { + ClanBody::Standard { + second_resource, .. + } + | ClanBody::Spatial { + second_resource, .. + } => second_resource, + }; + let raw_count = second_resource.tag as u32; + let count = + usize::try_from(raw_count).map_err(|_| EngineError::MissionAiRecordSlots { + clan_index, + raw_count, + allocated_slots, + message: "count does not fit this platform's address space".to_string(), + })?; + let requested_total = allocated_slots.checked_add(count); + if requested_total.is_none_or(|total| total > MAX_MISSION_AI_RECORD_SLOTS) { + return Err(EngineError::MissionAiRecordSlots { + clan_index, + raw_count, + allocated_slots, + message: format!( + "cumulative mission count exceeds the Rust allocation budget of {MAX_MISSION_AI_RECORD_SLOTS} u32 slots" + ), + }); + } + let mut record_slots = Vec::new(); + record_slots.try_reserve_exact(count).map_err(|source| { + EngineError::MissionAiRecordSlots { + clan_index, + raw_count, + allocated_slots, + message: format!("could not reserve {count} u32 slots: {source}"), + } + })?; + record_slots.resize(count, u32::MAX); + allocated_slots = requested_total.expect("checked cumulative AI slot count"); + ai_startup_states.push(MissionAiStartupState { + clan_index, + record_slots, + }); + } + } for script_base in script_bases { let base_path = String::from_utf8_lossy(&script_base.path_raw).into_owned(); let script_path = if base_path.to_ascii_lowercase().ends_with(".scr") { @@ -6353,6 +7112,7 @@ fn load_mission_scripts( path: normalized.as_str().to_string(), message: source.to_string(), })?; + let formula_table = load_script_formula_table(vfs, &base_path)?; if first_base_path.is_none() { first_base_path = Some(base_path.clone()); } @@ -6361,6 +7121,7 @@ fn load_mission_scripts( base_path, script_path: normalized.as_str().to_string(), package, + formula_table, init_execution: MissionScriptInitExecution::Missing, behavior_warm_start: None, }); @@ -6372,7 +7133,7 @@ fn load_mission_scripts( let (init_states, varset_states) = if asset_scope == MissionAssetScope::Full { (Vec::new(), Vec::new()) } else if let Some(varset) = &varset { - let init_states = resolve_handler19_init_states( + let init_states = resolve_init_states( &mission .clans .iter() @@ -6389,6 +7150,7 @@ fn load_mission_scripts( }; Ok(LoadedMissionScripts { bundles, + ai_startup_states, varset, init_states, varset_states, @@ -6417,7 +7179,7 @@ fn start_mission_scripts( )); if let Some(varset) = scripts.varset.as_ref() { - init_states.extend(resolve_handler19_init_states( + init_states.extend(resolve_init_states( &clan_anchors, std::slice::from_mut(bundle), varset, @@ -6459,12 +7221,12 @@ fn materialize_script_varset_states( return Err(EngineError::ScriptInit { clan_index: bundle.clan_index, message: format!( - "Handler(19) write index {} is outside the instantiated varset", + "Init write index {} is outside the instantiated varset", write.index ), }); }; - *value = VarSetDefault::Dword(write.value); + *value = write.value; } } Ok(MissionScriptVarSetState { @@ -6475,7 +7237,7 @@ fn materialize_script_varset_states( .collect() } -fn resolve_handler19_init_states( +fn resolve_init_states( clan_anchors: &[[f32; 2]], bundles: &mut [MissionScriptBundle], varset: &MissionScriptVarSet, @@ -6508,15 +7270,15 @@ fn resolve_handler19_init_states( }; match varset .declarations - .resolve_handler19_init(&bundle.package, input) + .resolve_init(&bundle.package, input, bundle.formula_table.as_ref()) .map_err(|source| EngineError::ScriptInit { clan_index: bundle.clan_index, message: source.to_string(), })? { - Handler19InitResolution::Missing => { + ScriptInitResolution::Missing => { bundle.init_execution = MissionScriptInitExecution::Missing; } - Handler19InitResolution::Executed { + ScriptInitResolution::Executed { event_index, event_word, writes, @@ -6531,7 +7293,7 @@ fn resolve_handler19_init_states( }); } } - Handler19InitResolution::Unsupported { + ScriptInitResolution::Unsupported { event_index, instruction_index, error, @@ -6542,7 +7304,7 @@ fn resolve_handler19_init_states( error, }; } - Handler19InitResolution::Unsupported { + ScriptInitResolution::Unsupported { event_index, instruction_index, error, @@ -6631,9 +7393,983 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi #[cfg(test)] mod tests { use super::*; - use fparkan_script::ScriptDispatchSelector; + use fparkan_assets::PreparedControlPointList; use fparkan_vfs::{DirectoryVfs, MemoryVfs, OverlayVfs, VfsEntry, VfsMetadata}; use std::path::{Path, PathBuf}; + #[test] + fn factory2_selector6_reads_fresh_constructor_zero() { + let device = synthetic_effective_control_device(2, 2, 0, None, None); + assert!(device.descriptor.fixed_words[27..43] + .iter() + .all(|word| *word == 0)); + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + device, + ])); + + assert_eq!( + query_control_device_table_selector(&table, 0, 6), + Ok(Some(0.0)) + ); + } + + #[test] + fn factory2_selector6_returns_the_stored_float_word() { + let mut device = synthetic_effective_control_device(2, 2, 0, None, None); + device.device174_bits = Some(6.25_f32.to_bits()); + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + device, + ])); + + assert_eq!( + query_control_device_table_selector(&table, 0, 6), + Ok(Some(6.25)) + ); + } + + #[test] + fn factory2_selector6_preserves_stored_negative_zero() { + let mut device = synthetic_effective_control_device(2, 2, 0, None, None); + device.device174_bits = Some((-0.0_f32).to_bits()); + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + device, + ])); + + assert_eq!( + query_control_device_table_selector(&table, 0, 6) + .expect("negative zero is a valid native float") + .expect("tag 2 has a selector-6 value") + .to_bits(), + (-0.0_f32).to_bits() + ); + } + + #[test] + fn factory2_selector6_reports_missing_wrong_factory_nonfinite_and_subnormal_values() { + let table_for = |device| { + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![device])) + }; + + let mut missing = synthetic_effective_control_device(2, 2, 0, None, None); + missing.device174_bits = None; + assert_eq!( + query_control_device_table_selector(&table_for(missing), 0, 6), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 2, + field: "+0x174", + }) + ); + + let mut wrong_factory = synthetic_effective_control_device(5, 2, 0, None, None); + wrong_factory.device174_bits = Some(1.0_f32.to_bits()); + assert_eq!( + query_control_device_table_selector(&table_for(wrong_factory), 0, 6), + Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag: 5 }) + ); + + let mut nonfinite = synthetic_effective_control_device(2, 2, 0, None, None); + nonfinite.device174_bits = Some(f32::INFINITY.to_bits()); + assert_eq!( + query_control_device_table_selector(&table_for(nonfinite), 0, 6), + Err(ControlResourceAccessError::NonFiniteValue { + factory_tag: 2, + field: "+0x174", + }) + ); + + let mut subnormal = synthetic_effective_control_device(2, 2, 0, None, None); + subnormal.device174_bits = Some(1); + assert_eq!( + query_control_device_table_selector(&table_for(subnormal), 0, 6), + Err(ControlResourceAccessError::SubnormalValue { + factory_tag: 2, + field: "+0x174", + }) + ); + } + + #[test] + fn factory2_selector6_returns_no_result_for_a_non_two_current_tag() { + let mut device = synthetic_effective_control_device(5, 5, 0, None, None); + device.device174_bits = None; + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + device, + ])); + + assert_eq!(query_control_device_table_selector(&table, 0, 6), Ok(None)); + } + + #[test] + fn factory2_pair_updates_keep_append_and_into_order_and_native_miss_bits() { + let mut root_record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut root_record, b"objects.rlb", b"root"); + let mut same_record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut same_record, b"OBJECTS.RLB", b"ROOT"); + let mut changed_record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut changed_record, b"objects.rlb", b"second"); + let mut empty_record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut empty_record, b"objects.rlb", b""); + let controls = [root_record, same_record, changed_record, empty_record] + .into_iter() + .enumerate() + .map(|(index, record)| { + Some(synthetic_component_control( + index, + vec![record], + PreparedControlEffectSection::Parsed(Vec::new()), + )) + }) + .collect(); + let (draft, assets, prototypes) = synthetic_control_fixture( + &[ + None, + Some(PROTOTYPE_TYPE_INTO), + Some(PROTOTYPE_TYPE_INTO), + Some(PROTOTYPE_TYPE_INTO), + ], + &[1, 1, 1, 1], + &[3, 0, 0, 0], + &[0, 0, 0, 0], + &[1, 0, 0, 0], + controls, + ); + let mut resolved = Vec::new(); + let mut resolver = |pair: &Factory2StringPair| { + resolved.push((pair.archive.clone(), pair.name.clone())); + match pair.name.as_str() { + "root" => Factory2PairOutcome::Value(6.25_f32.to_bits()), + "second" => Factory2PairOutcome::Value(9.5_f32.to_bits()), + _ => Factory2PairOutcome::Unrepresentable, + } + }; + + let projection = build_control_device_projection_with_class( + 0, + &draft, + &assets, + Some(&(0..prototypes.len())), + &prototypes, + None, + &mut resolver, + ) + .expect("factory-2 append plus three INTO updates"); + + assert_eq!( + resolved, + [ + ("objects.rlb".to_string(), "root".to_string()), + ("objects.rlb".to_string(), "second".to_string()) + ], + "same pair skips recalculation and an empty name is a native miss" + ); + assert_eq!(projection.devices.len(), 1); + assert_eq!(projection.devices[0].factory_tag, 2); + assert_eq!( + projection.devices[0].device174_bits, + Some(9.5_f32.to_bits()) + ); + assert_eq!( + projection.devices[0] + .descriptor_provenance + .unit_component_index, + 3, + "INTO retains the last raw descriptor even when selector state is preserved" + ); + } + + #[test] + fn factory2_pair_parser_validates_nul_and_ascii_before_it() { + let mut record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut record, b"objects.rlb", b"name"); + assert_eq!( + factory2_pair_from_record(&record).map(|pair| (pair.archive, pair.name)), + Some(("objects.rlb".to_string(), "name".to_string())) + ); + + record.fixed_words[27..35].fill(u32::from_le_bytes(*b"ABCD")); + assert!(factory2_pair_from_record(&record).is_none(), "missing NUL"); + + record.fixed_words[27] = u32::from_le_bytes([0xff, 0, 0, 0]); + assert!( + factory2_pair_from_record(&record).is_none(), + "non-ASCII byte" + ); + + record.fixed_words[27] = u32::from_le_bytes([b'a', 0, b'b', 0]); + let first = factory2_pair_from_record(&record).expect("NUL ends the archive string"); + assert_eq!(first.archive, "a"); + let mut different_padding = record.clone(); + different_padding.fixed_words[27] = u32::from_le_bytes([b'A', 0, b'c', 0]); + let second = factory2_pair_from_record(&different_padding) + .expect("native C-string comparison ignores bytes after NUL"); + assert!(first.matches_ascii_case(&second)); + } + + #[test] + fn factory2_unrepresentable_pair_clears_only_the_available_projection() { + let mut device = synthetic_effective_control_device(2, 2, 0, None, None); + device.device174_bits = Some(6.25_f32.to_bits()); + let mut replacement = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut device.descriptor, b"objects.rlb", b"known"); + set_factory2_pair(&mut replacement, b"objects.rlb", b"unknown"); + let previous_pair = factory2_pair_from_record(&device.descriptor); + let mut resolver = |_: &Factory2StringPair| Factory2PairOutcome::Unrepresentable; + apply_factory2_pair_update(&mut device, previous_pair, &replacement, &mut resolver); + + assert_eq!(device.device174_bits, None); + assert_eq!( + control_device_float(&device, device.device174_bits, "+0x174"), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 2, + field: "+0x174", + }) + ); + } + + #[test] + fn factory2_pair_cache_key_folds_ascii_case() { + let upper = Factory2StringPair { + archive: "OBJECTS.RLB".to_string(), + name: "FOREIGN".to_string(), + }; + let lower = Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "foreign".to_string(), + }; + + assert_eq!(upper.cache_key(), lower.cache_key()); + } + + #[test] + fn factory2_ndp_maximum_uses_ordered_f32_sum_and_accepts_empty_tables() { + let mut rows = [0.0_f32; 3].map(|max_volume| PreparedControlNodeDataRow { + raw_flags: 0, + max_volume, + mobility_per_volume: 0.0, + effect: PreparedControlResourcePair { + archive: [0; 32], + name: [0; 32], + }, + }); + rows[0].max_volume = 16_777_216.0; + rows[1].max_volume = 1.0; + rows[2].max_volume = -16_777_216.0; + + assert_eq!( + factory2_node_data_maximum_bits(&rows), + Ok(0.0_f32.to_bits()) + ); + assert_eq!(factory2_node_data_maximum_bits(&[]), Ok(0.0_f32.to_bits())); + assert_eq!( + factory2_node_data_maximum_bits(&[PreparedControlNodeDataRow { + max_volume: f32::from_bits(1), + ..rows[0].clone() + }]), + Err("NDP Max float") + ); + } + + fn factory2_test_nres(entries: &[(&[u8], u32, u32, &[u8])]) -> Vec<u8> { + let mut bytes = vec![0; 16]; + let mut offsets = Vec::with_capacity(entries.len()); + for (_, _, _, payload) in entries { + offsets.push(u32::try_from(bytes.len()).expect("payload offset fits u32")); + bytes.extend_from_slice(payload); + bytes.resize(bytes.len() + (8 - bytes.len() % 8) % 8, 0); + } + let mut sorted_indices = (0..entries.len()).collect::<Vec<_>>(); + sorted_indices.sort_by(|left, right| entries[*left].0.cmp(entries[*right].0)); + let mut order = vec![0_u32; entries.len()]; + for (rank, index) in sorted_indices.into_iter().enumerate() { + order[index] = u32::try_from(rank).expect("entry rank fits u32"); + } + for (index, (name, type_id, attr3, payload)) in entries.iter().enumerate() { + assert!(name.len() < 36, "NRes fixture name needs a bounded NUL"); + for word in [ + *type_id, + 0, + 0, + u32::try_from(payload.len()).expect("payload length fits u32"), + *attr3, + ] { + bytes.extend_from_slice(&word.to_le_bytes()); + } + let mut name_raw = [0; 36]; + name_raw[..name.len()].copy_from_slice(name); + bytes.extend_from_slice(&name_raw); + bytes.extend_from_slice(&offsets[index].to_le_bytes()); + bytes.extend_from_slice(&order[index].to_le_bytes()); + } + bytes[0..4].copy_from_slice(b"NRes"); + bytes[4..8].copy_from_slice(&0x100_u32.to_le_bytes()); + bytes[8..12].copy_from_slice( + &u32::try_from(entries.len()) + .expect("entry count fits u32") + .to_le_bytes(), + ); + let total_size = u32::try_from(bytes.len()).expect("archive size fits u32"); + bytes[12..16].copy_from_slice(&total_size.to_le_bytes()); + bytes + } + + fn factory2_test_object_refs(items: &[(&[u8], &[u8])]) -> Vec<u8> { + let mut bytes = Vec::with_capacity(items.len() * 64); + for (archive, name) in items { + assert!(archive.len() < 32 && name.len() < 32); + let mut record = [0; 64]; + record[..archive.len()].copy_from_slice(archive); + record[32..32 + name.len()].copy_from_slice(name); + bytes.extend_from_slice(&record); + } + bytes + } + + fn factory2_test_control_payload( + pair: Option<(&[u8], &[u8])>, + effect_opcode: Option<u32>, + ) -> Vec<u8> { + let mut payload = vec![0; 5 * 4 + 108]; + if let Some((archive, name)) = pair { + payload[12..16].copy_from_slice(&1_u32.to_le_bytes()); + let record_start = payload.len(); + payload.resize(record_start + 0xb0 + 8, 0); + payload[record_start..record_start + 4].copy_from_slice(&2_u32.to_le_bytes()); + let archive_start = record_start + 27 * 4; + payload[archive_start..archive_start + archive.len()].copy_from_slice(archive); + let name_start = record_start + 35 * 4; + payload[name_start..name_start + name.len()].copy_from_slice(name); + } + if let Some(opcode) = effect_opcode { + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + let group_start = payload.len(); + payload.resize(group_start + 0x54 + 4 + 0x64, 0); + payload[group_start + 0x54..group_start + 0x58].copy_from_slice(&1_u32.to_le_bytes()); + payload[group_start + 0x58 + 0x0c..group_start + 0x58 + 0x10] + .copy_from_slice(&opcode.to_le_bytes()); + } + payload + } + + fn factory2_test_mesh_payload() -> Vec<u8> { + let res2 = vec![0; 0x8c]; + factory2_test_nres(&[ + (b"Res1", 1, 38, &[]), + (b"Res13", 13, 0, &[]), + (b"Res2", 2, 0, &res2), + (b"Res3", 3, 0, &[]), + (b"Res6", 6, 0, &[]), + ]) + } + + fn factory2_test_mat0() -> Vec<u8> { + let mut bytes = vec![0; 4 + 34]; + bytes[0..2].copy_from_slice(&1_u16.to_le_bytes()); + bytes[22..27].copy_from_slice(b"TEX_A"); + bytes + } + + fn factory2_test_texm() -> Vec<u8> { + let mut bytes = Vec::new(); + for word in [0x6d78_6554_u32, 1, 1, 1, 0, 0, 0, 565] { + bytes.extend_from_slice(&word.to_le_bytes()); + } + bytes.extend_from_slice(&0xffff_u16.to_le_bytes()); + bytes + } + + fn factory2_test_resolver_archives( + foreign_control: &[u8], + ndp_maxima: &[f32], + ) -> Vec<(&'static str, Vec<u8>)> { + let host_refs = factory2_test_object_refs(&[ + (b"static.rlb", b"model.msh"), + (b"controls.rlb", b"host.ctl"), + ]); + let foreign_refs = factory2_test_object_refs(&[ + (b"static.rlb", b"model.msh"), + (b"controls.rlb", b"foreign.ctl"), + (b"providers.rlb", b"foreign.ndp"), + ]); + let objects = factory2_test_nres(&[ + (b"foreign", CONTROL_CLASS_SOURCE_BULL, 0, &foreign_refs), + (b"host", CONTROL_CLASS_SOURCE_BULL, 0, &host_refs), + ]); + let mesh = factory2_test_mesh_payload(); + let static_archive = factory2_test_nres(&[ + (b"model.msh", 0, 0, &mesh), + (b"model.wea", 0x5241_4557, 0, b"1\n0 MAT_A\n"), + ]); + let host_control = factory2_test_control_payload(Some((b"objects.rlb", b"foreign")), None); + let control_archive = factory2_test_nres(&[ + (b"foreign.ctl", 0, 0, foreign_control), + (b"host.ctl", 0, 0, &host_control), + ]); + let mut node_data = vec![0; 4 + ndp_maxima.len() * 76]; + node_data[..4].copy_from_slice( + &u32::try_from(ndp_maxima.len()) + .expect("NDP count fits u32") + .to_le_bytes(), + ); + for (index, maximum) in ndp_maxima.iter().enumerate() { + let start = 4 + index * 76; + node_data[start + 4..start + 8].copy_from_slice(&maximum.to_bits().to_le_bytes()); + } + let node_data_archive = factory2_test_nres(&[(b"foreign.ndp", 0, 0, &node_data)]); + let mat0 = factory2_test_mat0(); + let material_archive = factory2_test_nres(&[(b"MAT_A", 0x3054_414D, 0, &mat0)]); + let texm = factory2_test_texm(); + let texture_archive = factory2_test_nres(&[(b"TEX_A", 0, 0, &texm)]); + vec![ + ("objects.rlb", objects), + ("static.rlb", static_archive), + ("controls.rlb", control_archive), + ("providers.rlb", node_data_archive), + ("material.lib", material_archive), + ("textures.lib", texture_archive), + ] + } + + fn factory2_test_resolver_vfs(foreign_control: &[u8]) -> Arc<dyn Vfs> { + factory2_test_resolver_vfs_with_maxima(foreign_control, &[]) + } + + fn factory2_test_resolver_vfs_with_maxima( + foreign_control: &[u8], + ndp_maxima: &[f32], + ) -> Arc<dyn Vfs> { + let mut files = MemoryVfs::default(); + for (path, bytes) in factory2_test_resolver_archives(foreign_control, ndp_maxima) { + files.insert( + normalize_engine_path("factory-2 resolver fixture", path).expect("archive path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + Arc::new(files) + } + + fn factory2_test_fxid_payload(opcodes: &[u32]) -> Vec<u8> { + let command_size = |opcode| match opcode { + 1 => 224, + 2 => 148, + 3 => 200, + 4 => 204, + 5 => 112, + 6 => 4, + 7 | 9 | 10 => 208, + 8 => 248, + _ => panic!("fixture opcode {opcode} is not framed"), + }; + let mut payload = vec![0; 60]; + payload[..4].copy_from_slice( + &u32::try_from(opcodes.len()) + .expect("FX command count fits u32") + .to_le_bytes(), + ); + for opcode in opcodes { + let start = payload.len(); + payload.resize(start + command_size(*opcode), 0); + payload[start..start + 4].copy_from_slice(&opcode.to_le_bytes()); + } + payload + } + + fn factory2_test_fxid_manager( + entries: &[(&[u8], u32, &[u8])], + ) -> AssetManager<CachedResourceRepository> { + let archive_entries = entries + .iter() + .map(|(name, type_id, payload)| (*name, *type_id, 0, *payload)) + .collect::<Vec<_>>(); + let archive = factory2_test_nres(&archive_entries); + let mut files = MemoryVfs::default(); + files.insert( + normalize_engine_path("factory-2 FX fixture", "effects.rlb").expect("FX archive path"), + Arc::from(archive.into_boxed_slice()), + ); + let vfs: Arc<dyn Vfs> = Arc::new(files); + AssetManager::new(CachedResourceRepository::new(vfs)) + } + + fn factory2_test_effect_resource_pair( + archive: &[u8], + name: &[u8], + ) -> PreparedControlResourcePair { + fn fixed_string(raw: &[u8]) -> [u8; 32] { + assert!(raw.len() <= 32); + let mut bytes = [0; 32]; + bytes[..raw.len()].copy_from_slice(raw); + bytes + } + + PreparedControlResourcePair { + archive: fixed_string(archive), + name: fixed_string(name), + } + } + + fn factory2_test_effect_row( + table_index: usize, + row_index: usize, + archive: &[u8], + name: &[u8], + point_offsets: [u32; 3], + ) -> PreparedControlEffectRow { + PreparedControlEffectRow { + table_index, + row_index, + raw_condition: [0; 3], + point_offsets, + selector: 0, + resource: factory2_test_effect_resource_pair(archive, name), + } + } + + fn factory2_test_control_effects( + rows: Vec<PreparedControlEffectRow>, + initial_effect_group: Option<usize>, + point_count: Option<usize>, + ) -> PreparedComponentControl { + PreparedComponentControl { + source: synthetic_resource_key(940), + native_profile_words: [0; 27], + model_node_count: 3, + bindings: Vec::new(), + timeline_rows: Vec::new(), + typed_records: Vec::new(), + node_data: None, + effect_section: PreparedControlEffectSection::Parsed(rows), + initial_effect_group, + point_list: point_count.map(|count| PreparedControlPointList { + source: synthetic_resource_key(941), + points: (0..count) + .map(|_| fparkan_assets::PreparedControlPoint { + raw_flags: 0, + raw_signed_field_04: 0, + raw_signed_field_08: 0, + position: [0.0; 3], + direction: [0.0; 3], + name: [0; 32], + }) + .collect(), + }), + } + } + + fn factory2_test_full_load_vfs(foreign_control: &[u8]) -> Arc<dyn Vfs> { + factory2_test_full_load_vfs_with_maxima(foreign_control, &[]) + } + + fn factory2_test_full_load_vfs_with_maxima( + foreign_control: &[u8], + ndp_maxima: &[f32], + ) -> Arc<dyn Vfs> { + fn push_u32(bytes: &mut Vec<u8>, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) { + push_u32(bytes, u32::try_from(value.len()).expect("length fits u32")); + bytes.extend_from_slice(value); + } + fn push_f32(bytes: &mut Vec<u8>, value: f32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + let mut mission = Vec::new(); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + push_u32(&mut mission, 6); + push_u32(&mut mission, 1); + push_lp(&mut mission, b"Synthetic clan"); + push_u32(&mut mission, u32::MAX); + push_f32(&mut mission, 0.0); + push_f32(&mut mission, 0.0); + push_u32(&mut mission, 1); + push_lp(&mut mission, b"MISSIONS/SCRIPTS/shared"); + push_u32(&mut mission, 0); + push_lp(&mut mission, b""); + push_u32(&mut mission, 0); + push_u32(&mut mission, 0); + push_u32(&mut mission, 10); + push_u32(&mut mission, 1); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0x8000_0002); + push_lp(&mut mission, b"UNITS/FACTORY2/host.dat"); + push_u32(&mut mission, 0); + push_u32(&mut mission, 0); + for value in [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0] { + push_f32(&mut mission, value); + } + push_lp(&mut mission, b"host"); + push_u32(&mut mission, 0); + push_u32(&mut mission, 0); + push_u32(&mut mission, 0); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + push_lp(&mut mission, b"DATA\\MAPS\\SYNTHETIC\\land"); + push_u32(&mut mission, 0); + push_lp(&mut mission, b""); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + + let mut unit_dat = vec![0; 8 + 112]; + unit_dat[4..8].copy_from_slice(&0_u32.to_le_bytes()); + unit_dat[8..19].copy_from_slice(b"objects.rlb"); + unit_dat[40..44].copy_from_slice(b"host"); + unit_dat[76..80].copy_from_slice(&0_i32.to_le_bytes()); + + let mut files = MemoryVfs::default(); + for (path, bytes) in [ + ("MISSIONS/TEST/data.tma", mission), + ("UNITS/FACTORY2/host.dat", unit_dat), + ("DATA/MAPS/SYNTHETIC/Land.msh", empty_land_mesh()), + ("DATA/MAPS/SYNTHETIC/Land.map", minimal_land_map()), + ( + "MISSIONS/SCRIPTS/shared.scr", + encode_script_package(&ScriptPackage { + opcode_handler_count: 73, + events: Vec::new(), + trailing_bytes: Vec::new(), + raw: Arc::from(&b""[..]), + }), + ), + ( + "MISSIONS/SCRIPTS/shared.var", + b"VAR(DWORD, value, 0)\n".to_vec(), + ), + ] { + files.insert( + normalize_engine_path("factory-2 full-load fixture", path).expect("test path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + for (path, bytes) in factory2_test_resolver_archives(foreign_control, ndp_maxima) { + files.insert( + normalize_engine_path("factory-2 full-load archive", path).expect("archive path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + files.insert( + normalize_engine_path("factory-2 full-load BuildDat", "BuildDat.lst") + .expect("BuildDat path"), + Arc::from(&b""[..]), + ); + Arc::new(files) + } + + #[test] + fn factory2_resolver_accepts_empty_effects_bull_and_ordered_ndp_rows() { + let control = factory2_test_control_payload(None, None); + let vfs = + factory2_test_resolver_vfs_with_maxima(&control, &[16_777_216.0, 1.0, -16_777_216.0]); + let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone())); + let pair = Factory2StringPair { + archive: "OBJECTS.RLB".to_string(), + name: "FOREIGN".to_string(), + }; + assert_eq!( + resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair) + .expect("valid foreign BULL projection"), + Factory2PairOutcome::Value(0.0_f32.to_bits()) + ); + } + + #[test] + fn factory2_fx_guard_accepts_supported_commands_only_from_selected_group() { + let fxid = factory2_test_fxid_payload(&[2, 3, 4, 5, 7, 8, 9]); + let manager = factory2_test_fxid_manager(&[(b"selected", FXID_KIND, &fxid)]); + let mut selected = factory2_test_effect_row(0, 0, b"effects.rlb", b"selected", [0, 1, 2]); + selected.resource.archive[12] = 0xff; + selected.resource.name[9] = 0xfe; + let unselected = factory2_test_effect_row(1, 0, b"missing.rlb", b"missing", [0, 1, 2]); + let control = factory2_test_control_effects(vec![selected, unselected], Some(0), Some(3)); + + assert!( + factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("valid selected FX resource") + ); + } + + #[test] + fn factory2_fx_guard_rejects_unknown_types_missing_inputs_and_bad_points() { + for opcode in [1, 6, 10] { + let fxid = factory2_test_fxid_payload(&[opcode]); + let manager = factory2_test_fxid_manager(&[(b"effect", FXID_KIND, &fxid)]); + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("framed unknown FX command remains unsupported") + ); + } + + let fxid = factory2_test_fxid_payload(&[2]); + let manager = factory2_test_fxid_manager(&[(b"effect", FXID_KIND, &fxid)]); + for (point_offsets, point_count) in [ + ([0, 1, 0], Some(1)), + ([0, u32::MAX, 0], Some(1)), + ([0, 0, 0], None), + ] { + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", point_offsets); + let control = factory2_test_control_effects(vec![row], Some(0), point_count); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("unsupported CPT selector remains unknown") + ); + } + + let missing_entry = factory2_test_fxid_manager(&[]); + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"absent", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&missing_entry, &control, &[4]) + .expect("missing FX entry is not a decode error") + ); + + let row = factory2_test_effect_row(0, 0, b"absent.rlb", b"effect", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("missing FX archive is unsupported input") + ); + + let wrong_type = factory2_test_fxid_manager(&[(b"effect", 0, &fxid)]); + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&wrong_type, &control, &[4]) + .expect("wrong NRes type is unsupported, not malformed FXID") + ); + + for archive in [ + [b'x'; 32], + { + let mut raw = [0; 32]; + raw[0] = 0xff; + raw[1] = 0; + raw + }, + factory2_test_effect_resource_pair(b"../effects.rlb", b"effect").archive, + ] { + let mut row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + row.resource.archive = archive; + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("invalid FX path or string remains unsupported") + ); + } + + let mut row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + row.resource.name = [b'x'; 32]; + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("unterminated resource name is unsupported input") + ); + } + + #[test] + fn factory2_fx_guard_fails_on_malformed_recognized_fxid() { + let mut malformed_fxid = vec![0; 64]; + malformed_fxid[..4].copy_from_slice(&1_u32.to_le_bytes()); + malformed_fxid[60..64].copy_from_slice(&2_u32.to_le_bytes()); + let manager = factory2_test_fxid_manager(&[(b"effect", FXID_KIND, &malformed_fxid)]); + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + + assert!(matches!( + factory2_selected_fx_resources_are_representable(&manager, &control, &[4]), + Err(AssetPreparationError::InvalidPrototype(_)) + )); + } + + #[test] + fn factory2_unsupported_ndp_numeric_projection_keeps_valid_mission_loaded() { + let control = factory2_test_control_payload(None, None); + let request = || MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }; + let unsupported_maxima = [ + ("subnormal", vec![f32::from_bits(1)]), + ("nonfinite", vec![f32::NAN]), + ("ordered sum overflow", vec![f32::MAX, f32::MAX]), + ]; + + for (case, maxima) in unsupported_maxima { + let vfs = factory2_test_full_load_vfs_with_maxima(&control, &maxima); + let mut engine = create(EngineServices::new(vfs)).expect("factory-2 test engine"); + load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .unwrap_or_else(|error| { + panic!("{case} resolver input aborted a structurally valid load: {error:?}") + }); + + let table = loaded_mission_control_device_tables(&engine) + .expect("loaded Control tables") + .first() + .expect("synthetic host Control table"); + let devices = match &table.status { + MissionControlDeviceTableStatus::Available(devices) => devices, + MissionControlDeviceTableStatus::Unavailable(reason) => { + panic!("synthetic Control table unavailable for {case}: {reason:?}"); + } + }; + assert_eq!(devices.len(), 1, "{case}"); + assert_eq!( + devices[0].device174_bits, None, + "unsupported {case} must stay unknown rather than become zero or NativeMiss" + ); + assert_eq!( + query_loaded_mission_control_device_selector(&engine, table.handle, 0, 6), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 2, + field: "+0x174", + }), + "unsupported {case} remains visible as an unavailable native value" + ); + } + } + + #[test] + fn factory2_resolver_accepts_only_known_initial_effect_opcodes() { + for opcode in [1, 12] { + let control = factory2_test_control_payload(None, Some(opcode)); + let vfs = factory2_test_resolver_vfs(&control); + let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone())); + let pair = Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "foreign".to_string(), + }; + + assert_eq!( + resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair) + .expect("complete known opcode group"), + Factory2PairOutcome::Value(0.0_f32.to_bits()), + "initial opcode {opcode} is covered" + ); + } + + let control = factory2_test_control_payload(None, Some(4)); + let vfs = factory2_test_resolver_vfs(&control); + let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone())); + let pair = Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "foreign".to_string(), + }; + assert_eq!( + resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair) + .expect("opcode 4 remains representable as an unknown shape"), + Factory2PairOutcome::Unrepresentable + ); + } + + #[test] + fn factory2_resolver_keeps_unknown_and_nonbare_pairs_unrepresentable() { + let vfs = factory2_test_resolver_vfs(&factory2_test_control_payload(None, None)); + let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone())); + for pair in [ + Factory2StringPair { + archive: "static.rlb".to_string(), + name: "foreign".to_string(), + }, + Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "foreign.msh".to_string(), + }, + Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "nested/foreign".to_string(), + }, + Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "missing".to_string(), + }, + ] { + assert_eq!( + resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair) + .expect("unknown mapping does not become a native miss"), + Factory2PairOutcome::Unrepresentable + ); + } + } + + #[test] + fn factory2_foreign_asset_error_keeps_loaded_world_and_rng() { + let valid_vfs = factory2_test_full_load_vfs_with_maxima( + &factory2_test_control_payload(None, None), + &[6.25], + ); + let mut engine = create(EngineServices::new(valid_vfs)).expect("factory-2 test engine"); + let request = || MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }; + load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("initial valid full load"); + let previous_summary = loaded_mission(&engine).expect("loaded mission").clone(); + assert_ne!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE); + let control_table = + &loaded_mission_control_device_tables(&engine).expect("Control table")[0]; + let MissionControlDeviceTableStatus::Available(devices) = &control_table.status else { + panic!("synthetic host Control table should be available"); + }; + assert_eq!(devices.len(), 1); + assert_eq!( + devices[0].device174_bits, + Some(6.25_f32.to_bits()), + "full-load builder resolves the foreign pair lazily" + ); + let handle = control_table.handle; + let retained_transform = TransformState { + position: [17.0_f32.to_bits(), 29.0_f32.to_bits(), 41.0_f32.to_bits()], + orientation: [0x3f00_0000; 3], + scale: [0x3f80_0000; 3], + }; + set_transform(&mut engine.world, handle, retained_transform).expect("set sentinel pose"); + let previous_rng = engine.behavior_rng_state; + engine.services.vfs = Some(factory2_test_full_load_vfs(&[0, 0, 0])); + + let error = load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_200, + research_creation_gate: false, + }, + ) + .expect_err("malformed foreign CTLD aborts staged loading"); + + assert!(matches!( + error, + EngineError::AssetPreparation { + mission, + source: AssetPreparationError::InvalidPrototype(_), + } if mission == "MISSIONS/TEST/data.tma" + )); + assert_eq!(loaded_mission(&engine), Some(&previous_summary)); + assert_eq!(engine.behavior_rng_state, previous_rng); + assert_eq!( + transform_state(&engine.world, handle), + Ok(retained_transform) + ); + } fn basement_draft(header_flags: Option<u32>, raw_after_name: u32) -> MissionObjectDraft { MissionObjectDraft { @@ -6680,6 +8416,21 @@ mod tests { } } + fn set_factory2_pair(record: &mut PreparedControlTypedRecord, archive: &[u8], name: &[u8]) { + fn write_words(words: &mut [u32], value: &[u8]) { + assert!(value.len() < words.len() * 4, "fixed name needs a NUL"); + assert!(value.is_ascii(), "synthetic pair must be ASCII"); + let mut bytes = [0; 32]; + bytes[..value.len()].copy_from_slice(value); + for (word, bytes) in words.iter_mut().zip(bytes.chunks_exact(4)) { + *word = u32::from_le_bytes(bytes.try_into().expect("four-byte word")); + } + } + + write_words(&mut record.fixed_words[27..35], archive); + write_words(&mut record.fixed_words[35..43], name); + } + fn synthetic_component_control( index: usize, typed_records: Vec<PreparedControlTypedRecord>, @@ -6808,8 +8559,10 @@ mod tests { normals: None, uv0: None, uv1: None, + type16_present: false, indices: Vec::new(), batches: Vec::new(), + triangle_descriptors: None, node_names: None, animation: None, } @@ -7042,6 +8795,7 @@ mod tests { multiplier_bits, class9_selector0_scale_bits: class9_scale_bits, tag26_extra_rate_bits: (factory_tag == 26).then_some(0), + device174_bits: (factory_tag == 2).then_some(0), class9_values_bits: (factory_tag == 9).then_some([0x3f80_0000; 6]), class9_deltas_bits: (factory_tag == 9).then_some([0; 6]), native_word_50_bits, @@ -7086,6 +8840,95 @@ mod tests { } } + #[test] + fn behavior_control_grouping_uses_current_tags_and_table_ordinals() { + let tags = [2, 30, 1, 30, 2, 99, 1, 2, 30, 2, 1]; + let devices = tags + .into_iter() + .enumerate() + .map(|(ordinal, current_tag)| { + let mut device = synthetic_effective_control_device( + 5, + current_tag, + 1.0_f32.to_bits(), + None, + None, + ); + device.key = 100 + ordinal as u32; + device.descriptor.fixed_words[1] = 200 + ordinal as u32; + device + }) + .collect(); + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(devices)); + let state = project_mission_behavior_control_grouping(&table); + assert_eq!(state.original_id, Some(OriginalObjectId(7))); + assert_eq!( + state.handle, + ObjectHandle { + generation: 5, + slot: 7 + } + ); + assert_eq!( + state.grouping, + Ok(MissionBehaviorControlGrouping { + ungrouped_candidate_device_ordinals: vec![0], + groups: vec![ + MissionBehaviorControlGroup { + header_device_ordinal: 2, + candidate_device_ordinals: vec![4], + tag30_device_ordinals: vec![3], + }, + MissionBehaviorControlGroup { + header_device_ordinal: 6, + candidate_device_ordinals: vec![7, 9], + tag30_device_ordinals: vec![8], + }, + MissionBehaviorControlGroup { + header_device_ordinal: 10, + candidate_device_ordinals: Vec::new(), + tag30_device_ordinals: Vec::new(), + }, + ], + }), + "group membership uses current descriptor tags and table ordinals, not factory tags or keys" + ); + + let empty = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new())); + assert_eq!( + project_mission_behavior_control_grouping(&empty).grouping, + Ok(MissionBehaviorControlGrouping::default()) + ); + + let headerless_devices = [2, 30, 2] + .into_iter() + .map(|current_tag| { + synthetic_effective_control_device(5, current_tag, 1.0_f32.to_bits(), None, None) + }) + .collect(); + let headerless = synthetic_control_device_table( + MissionControlDeviceTableStatus::Available(headerless_devices), + ); + assert_eq!( + project_mission_behavior_control_grouping(&headerless).grouping, + Ok(MissionBehaviorControlGrouping { + groups: Vec::new(), + ungrouped_candidate_device_ordinals: vec![0, 2], + }) + ); + + let reason = MissionControlDeviceTableUnavailableReason::NoControlResource; + let unavailable = synthetic_control_device_table( + MissionControlDeviceTableStatus::Unavailable(reason.clone()), + ); + assert_eq!( + project_mission_behavior_control_grouping(&unavailable).grouping, + Err(reason) + ); + } + fn control_budget_test_table(generator_coefficient: f32) -> MissionControlDeviceTableState { let mut generator = synthetic_effective_control_device( 19, @@ -7986,6 +9829,251 @@ mod tests { } #[test] + fn control_resource_selector_15_uses_last_cached_factories_and_current_fields() { + let mut first_class9 = synthetic_effective_control_device( + 9, + 19, + 90.0_f32.to_bits(), + Some(f32::INFINITY.to_bits()), + Some(9.0_f32.to_bits()), + ); + let mut first_class21 = synthetic_effective_control_device( + 21, + 19, + 90.0_f32.to_bits(), + Some(f32::INFINITY.to_bits()), + None, + ); + let mut class9 = synthetic_effective_control_device( + 9, + 5, + 99.0_f32.to_bits(), + Some(f32::NAN.to_bits()), + Some(99.0_f32.to_bits()), + ); + let mut class21 = synthetic_effective_control_device( + 21, + 26, + 90.0_f32.to_bits(), + Some(f32::INFINITY.to_bits()), + None, + ); + first_class9.native_word_50_bits = Some(0); + first_class21.native_word_50_bits = Some(0); + class9.native_word_50_bits = Some(0); + class21.native_word_50_bits = Some(0); + class9.key = u32::MAX; + class21.key = u32::MAX; + class9.coefficient_bits.as_mut().unwrap()[0] = 3.0_f32.to_bits(); + class9.class9_selector0_scale_bits = Some(4.0_f32.to_bits()); + class21.coefficient_bits.as_mut().unwrap()[..6].copy_from_slice(&[ + 1.0_f32.to_bits(), + 2.0_f32.to_bits(), + 3.0_f32.to_bits(), + 4.0_f32.to_bits(), + 5.0_f32.to_bits(), + 6.0_f32.to_bits(), + ]); + + let table = synthetic_control_device_table_with_providers( + MissionControlDeviceTableStatus::Available(vec![ + first_class9, + first_class21, + class9, + class21, + ]), + MissionControlNodeProviderTableStatus::Unavailable( + MissionControlNodeProviderTableUnavailableReason::MissingNodeData { + unit_component_index: 0, + source: None, + }, + ), + ); + + assert_eq!( + query_control_device_table_resource(&table, 15) + .expect("selector 15 uses current cached getter values") + .map(f32::to_bits), + Some(252.0_f32.to_bits()) + ); + } + + #[test] + fn control_resource_selector_15_preserves_descending_binary32_accumulation() { + let mut class9 = synthetic_effective_control_device( + 9, + 19, + 1.0_f32.to_bits(), + None, + Some(1.0_f32.to_bits()), + ); + let mut class21 = synthetic_effective_control_device(21, 19, 0, None, None); + class9.coefficient_bits.as_mut().unwrap()[0] = 1.0_f32.to_bits(); + class21.coefficient_bits.as_mut().unwrap()[..6].copy_from_slice(&[ + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + (-16_777_216.0_f32).to_bits(), + 16_777_216.0_f32.to_bits(), + 1.0_f32.to_bits(), + ]); + let forward = class21.coefficient_bits.unwrap()[..6] + .iter() + .map(|bits| f32::from_bits(*bits)) + .fold(0.0_f32, |sum, value| sum + value); + assert_eq!(forward.to_bits(), 1.0_f32.to_bits()); + + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&table, 15) + .expect("descending native order") + .map(f32::to_bits), + Some(0.0_f32.to_bits()) + ); + + class21.coefficient_bits.as_mut().unwrap()[..6].fill((-0.0_f32).to_bits()); + let negative_zero = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9, class21, + ])); + assert_eq!( + query_control_device_table_resource(&negative_zero, 15) + .expect("negative zero is retained") + .map(f32::to_bits), + Some((-0.0_f32).to_bits()) + ); + } + + #[test] + fn control_resource_selector_15_returns_no_result_without_both_cached_factories() { + let empty = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new())); + assert_eq!(query_control_device_table_resource(&empty, 15), Ok(None)); + + let class9_only = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device(9, 35, 1, None, None), + ])); + assert_eq!( + query_control_device_table_resource(&class9_only, 15), + Ok(None) + ); + + let mut class21 = synthetic_effective_control_device(21, 35, 1, None, None); + class21.coefficient_bits = None; + let class21_only = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class21, + ])); + assert_eq!( + query_control_device_table_resource(&class21_only, 15), + Ok(None) + ); + } + + #[test] + fn control_resource_selector_15_reports_missing_and_unsupported_inputs() { + let mut class9 = synthetic_effective_control_device( + 9, + 19, + 1.0_f32.to_bits(), + None, + Some(1.0_f32.to_bits()), + ); + let mut class21 = synthetic_effective_control_device(21, 19, 1.0_f32.to_bits(), None, None); + class21.coefficient_bits = None; + let missing_coefficients = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&missing_coefficients, 15), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 21, + field: "coefficient table", + }) + ); + + class21.coefficient_bits = Some([1.0_f32.to_bits(); 16]); + class9.coefficient_bits = None; + let missing_class9_coefficient = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&missing_class9_coefficient, 15), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 9, + field: "coefficient", + }) + ); + + class9.coefficient_bits = Some([1.0_f32.to_bits(); 16]); + class9.class9_selector0_scale_bits = None; + let missing_scale = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&missing_scale, 15), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 9, + field: "class-9 selector-0 scale", + }) + ); + + class9.class9_selector0_scale_bits = Some(1.0_f32.to_bits()); + class21.coefficient_bits.as_mut().unwrap()[5] = f32::INFINITY.to_bits(); + let nonfinite = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&nonfinite, 15), + Err(ControlResourceAccessError::NonFiniteValue { + factory_tag: 21, + field: "coefficient", + }) + ); + + class21.coefficient_bits.as_mut().unwrap()[5] = 1; + let subnormal = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&subnormal, 15), + Err(ControlResourceAccessError::SubnormalValue { + factory_tag: 21, + field: "coefficient", + }) + ); + + class21.coefficient_bits.as_mut().unwrap()[5] = f32::MAX.to_bits(); + class21.coefficient_bits.as_mut().unwrap()[4] = f32::MAX.to_bits(); + let overflowing_sum = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9, class21, + ])); + assert_eq!( + query_control_device_table_resource(&overflowing_sum, 15), + Err(ControlResourceAccessError::UnsupportedFloatRange { + factory_tag: Some(21), + field: "sum", + }) + ); + } + + #[test] fn control_resource_setter_updates_matching_rows_atomically_and_round_trips() { let mut table = synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ @@ -9621,11 +11709,13 @@ mod tests { trailing_bytes: Vec::new(), raw: Arc::from(&b""[..]), }, + formula_table: None, init_execution: MissionScriptInitExecution::Missing, behavior_warm_start: None, }; let mut scripts = LoadedMissionScripts { bundles: vec![bundle(0, 18, Vec::new()), bundle(1, 19, vec![0, 1, 2])], + ai_startup_states: Vec::new(), varset: Some(varset), ..LoadedMissionScripts::default() }; @@ -9637,7 +11727,7 @@ mod tests { assert!(matches!( error, EngineError::ScriptInitUnsupported { - source: Handler19ResolveError::UnsupportedSelector { + source: ScriptInitResolveError::UnsupportedSelector { found: fparkan_script::ScriptDispatchSelector::Handler(18), }, .. @@ -9655,6 +11745,193 @@ mod tests { assert_eq!(rng_state, 0x0029_e2c0); } + fn script_slots_test_mission(clans: &[(bool, bool, i32)]) -> MissionDocument { + fn push_u32(bytes: &mut Vec<u8>, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) { + push_u32(bytes, u32::try_from(value.len()).expect("length fits u32")); + bytes.extend_from_slice(value); + } + + let mut tma = Vec::new(); + push_u32(&mut tma, 1); + push_u32(&mut tma, 0); + push_u32(&mut tma, 6); + push_u32( + &mut tma, + u32::try_from(clans.len()).expect("clan count fits u32"), + ); + for (clan_index, (spatial, scripted, tag)) in clans.iter().enumerate() { + push_lp(&mut tma, b"synthetic"); + push_u32(&mut tma, u32::MAX); + tma.extend_from_slice(&(clan_index as f32).to_le_bytes()); + tma.extend_from_slice(&0.0_f32.to_le_bytes()); + let script_path: &[u8] = if *scripted { + b"MISSIONS/SCRIPTS/shared" + } else { + b"" + }; + if *spatial { + push_u32(&mut tma, 0); + push_lp(&mut tma, script_path); + push_u32(&mut tma, 0); + } else { + push_u32(&mut tma, 1); + push_lp(&mut tma, script_path); + push_u32(&mut tma, 0); + } + push_lp(&mut tma, b"unused.trf"); + push_u32(&mut tma, *tag as u32); + push_u32(&mut tma, 0); + } + push_u32(&mut tma, 10); + push_u32(&mut tma, 0); + push_lp(&mut tma, b""); + push_u32(&mut tma, 0); + push_lp(&mut tma, b""); + push_u32(&mut tma, 1); + push_u32(&mut tma, 0); + decode_mission_payload(Arc::from(tma), TmaProfile::Strict).expect("synthetic mission TMA") + } + + fn script_slots_test_tags(mission: &MissionDocument) -> Vec<i32> { + mission + .clans + .iter() + .map(|clan| match &clan.body { + ClanBody::Standard { + second_resource, .. + } + | ClanBody::Spatial { + second_resource, .. + } => second_resource.tag, + }) + .collect() + } + + fn script_slots_test_vfs() -> Arc<dyn Vfs> { + let package = encode_script_package(&ScriptPackage { + opcode_handler_count: 73, + events: Vec::new(), + trailing_bytes: Vec::new(), + raw: Arc::from(&b""[..]), + }); + let mut files = MemoryVfs::default(); + files.insert( + normalize_engine_path("script slots test", "MISSIONS/SCRIPTS/shared.scr") + .expect("script path"), + Arc::from(package.into_boxed_slice()), + ); + files.insert( + normalize_engine_path("script slots test", "MISSIONS/SCRIPTS/shared.var") + .expect("varset path"), + Arc::from(&b"VAR(DWORD, value, 0)\n"[..]), + ); + Arc::new(files) + } + + #[test] + fn full_mission_script_loader_builds_independent_unsigned_ai_slots_per_clan() { + let mission = script_slots_test_mission(&[ + (false, true, 2), + (true, true, 3), + (false, false, 4), + (false, true, 0), + ]); + let raw_tags = script_slots_test_tags(&mission); + let vfs = script_slots_test_vfs(); + + let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full) + .expect("Full script bundle load"); + assert_eq!( + full.bundles + .iter() + .map(|bundle| bundle.clan_index) + .collect::<Vec<_>>(), + [0, 1, 3], + "empty-script clans do not produce fake script bundles" + ); + assert_eq!( + full.ai_startup_states + .iter() + .map(|state| state.clan_index) + .collect::<Vec<_>>(), + [0, 1, 2, 3], + "Full startup retains a state for every TMA clan" + ); + assert_eq!( + full.ai_startup_states + .iter() + .map(|state| state.record_slots.clone()) + .collect::<Vec<_>>(), + [ + vec![u32::MAX; 2], + vec![u32::MAX; 3], + vec![u32::MAX; 4], + Vec::new(), + ] + ); + assert_ne!( + full.ai_startup_states[0].record_slots.as_ptr(), + full.ai_startup_states[1].record_slots.as_ptr(), + "each clan AI owns a separate vector" + ); + assert_ne!( + full.ai_startup_states[1].record_slots.as_ptr(), + full.ai_startup_states[2].record_slots.as_ptr(), + "empty-script AI also owns a separate vector" + ); + assert!(full + .bundles + .iter() + .all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing)); + + let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview) + .expect("preview script bundle load"); + assert!(preview.ai_startup_states.is_empty()); + assert_eq!( + preview + .bundles + .iter() + .map(|bundle| bundle.clan_index) + .collect::<Vec<_>>(), + [0, 1, 3] + ); + assert_eq!( + script_slots_test_tags(&mission), + raw_tags, + "loading leaves the signed TMA tag bits unchanged" + ); + } + + #[test] + fn full_mission_script_loader_rejects_unsigned_and_cumulative_ai_slot_overflow() { + let vfs = script_slots_test_vfs(); + + let negative_tag = script_slots_test_mission(&[(false, false, -1)]); + assert!(matches!( + load_mission_scripts(&vfs, &negative_tag, MissionAssetScope::Full), + Err(EngineError::MissionAiRecordSlots { + clan_index: 0, + raw_count: u32::MAX, + .. + }) + )); + + let over_budget = + script_slots_test_mission(&[(false, true, 600_000), (true, false, 400_001)]); + assert!(matches!( + load_mission_scripts(&vfs, &over_budget, MissionAssetScope::Full), + Err(EngineError::MissionAiRecordSlots { + clan_index: 1, + raw_count: 400_001, + allocated_slots: 600_000, + .. + }) + )); + } + #[test] fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() { let mut engine = create(EngineServices::default()).expect("engine"); @@ -9665,7 +11942,10 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::SingleMission, }, - 0, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, ) .expect_err("missing VFS"); assert!(matches!(err, EngineError::MissingVfs)); @@ -10032,6 +12312,8 @@ mod tests { [7.0_f32.to_bits(), 11.0_f32.to_bits(), 0, 0, 0]; draft.scale = [2.0, 3.0, 5.0]; + let mut resolve_factory2_pair = + |_: &Factory2StringPair| Factory2PairOutcome::Unrepresentable; let projection = build_control_device_projection_with_class( 0, &draft, @@ -10039,6 +12321,7 @@ mod tests { Some(&(0..prototypes.len())), &prototypes, Some(native_class), + &mut resolve_factory2_pair, ) .expect("base device and FAC0 projections"); let profile = projection @@ -11266,6 +13549,71 @@ mod tests { } #[test] + fn indexed_provider_value_query_400_reads_current_value_by_device_key() { + let mut device = synthetic_effective_control_device(2, 99, 9.0_f32.to_bits(), None, None); + device.key = 1; + device.native_word_50_bits = Some(0); + device.coefficient_bits = None; + device.descriptor.fixed_words[8] = 13.0_f32.to_bits(); + let table = synthetic_control_device_table_with_providers( + MissionControlDeviceTableStatus::Available(vec![device]), + MissionControlNodeProviderTableStatus::Available(vec![ + synthetic_node_provider_row(0, 0, 0.125_f32.to_bits()), + synthetic_node_provider_row(0, 1, 0.625_f32.to_bits()), + synthetic_node_provider_row(0, 2, 0.875_f32.to_bits()), + ]), + ); + + assert_eq!( + query_control_device_table_selector(&table, 0, 0x400), + Ok(Some(0.625_f32)), + "selector 0x400 returns the current type-1 value at the device's provider key" + ); + + let mut negative_zero = table.clone(); + let MissionControlNodeProviderTableStatus::Available(rows) = + &mut negative_zero.node_provider_status + else { + unreachable!(); + }; + rows[1].current_type1_value_bits = (-0.0_f32).to_bits(); + assert_eq!( + query_control_device_table_selector(&negative_zero, 0, 0x400) + .expect("negative-zero provider value") + .map(f32::to_bits), + Some((-0.0_f32).to_bits()) + ); + + let reason = MissionControlNodeProviderTableUnavailableReason::MissingNodeData { + unit_component_index: 0, + source: None, + }; + let unavailable = synthetic_control_device_table_with_providers( + table.status.clone(), + MissionControlNodeProviderTableStatus::Unavailable(reason.clone()), + ); + assert_eq!( + query_control_device_table_selector(&unavailable, 0, 0x400), + Err(ControlResourceAccessError::NodeProviderTableUnavailable( + reason + )) + ); + + let mut out_of_range = table.clone(); + let MissionControlDeviceTableStatus::Available(devices) = &mut out_of_range.status else { + unreachable!(); + }; + devices[0].key = 3; + assert_eq!( + query_control_device_table_selector(&out_of_range, 0, 0x400), + Err(ControlResourceAccessError::NodeProviderKeyOutOfRange { + key: 3, + row_count: 3, + }) + ); + } + + #[test] fn synthetic_into_builder_appends_all_rows_and_overrides_only_first_record() { let append = synthetic_control_table( &[None, Some(PROTOTYPE_TYPE_INTO)], @@ -11511,7 +13859,10 @@ mod tests { key: "MISSIONS/TEST/MINE/data.tma".to_string(), mode: MissionMode::SingleMission, }, - 0, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, ) .expect("full normal mission load"); assert_eq!(loaded.object_count, 1); @@ -12207,6 +14558,392 @@ mod tests { assert_eq!(rows[stage_key as usize].initial_amount_bits, stage_initial); } + fn test_push_u32(bytes: &mut Vec<u8>, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn test_push_i32(bytes: &mut Vec<u8>, value: i32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn build_nres(entries: &[(u32, u32, u32, u32, Vec<u8>)]) -> Vec<u8> { + let mut bytes = vec![0; 16]; + let mut offsets = Vec::with_capacity(entries.len()); + for (_, _, _, _, payload) in entries { + offsets.push(u32::try_from(bytes.len()).expect("offset fits u32")); + bytes.extend_from_slice(payload); + let padding = (8 - bytes.len() % 8) % 8; + bytes.resize(bytes.len() + padding, 0); + } + for (index, (type_id, attr1, attr2, attr3, payload)) in entries.iter().enumerate() { + test_push_u32(&mut bytes, *type_id); + test_push_u32(&mut bytes, *attr1); + test_push_u32(&mut bytes, *attr2); + test_push_u32( + &mut bytes, + u32::try_from(payload.len()).expect("payload fits u32"), + ); + test_push_u32(&mut bytes, *attr3); + let mut name = [0; 36]; + name[..4].copy_from_slice(b"Test"); + bytes.extend_from_slice(&name); + test_push_u32(&mut bytes, offsets[index]); + test_push_u32(&mut bytes, u32::try_from(index).expect("index fits u32")); + } + bytes[..4].copy_from_slice(b"NRes"); + bytes[4..8].copy_from_slice(&0x100_u32.to_le_bytes()); + let count = u32::try_from(entries.len()).expect("count fits u32"); + bytes[8..12].copy_from_slice(&count.to_le_bytes()); + let size = u32::try_from(bytes.len()).expect("size fits u32"); + bytes[12..16].copy_from_slice(&size.to_le_bytes()); + bytes + } + + fn empty_land_mesh() -> Vec<u8> { + build_nres(&[ + (1, 0, 0, 38, Vec::new()), + (2, 0, 0, 0, vec![0; 0x8c]), + (3, 0, 0, 12, Vec::new()), + (4, 0, 0, 4, Vec::new()), + (5, 0, 0, 4, Vec::new()), + (18, 0, 0, 4, Vec::new()), + (14, 0, 0, 4, Vec::new()), + (11, 0, 0, 4, Vec::new()), + (21, 0, 0, 28, Vec::new()), + ]) + } + + fn minimal_land_map() -> Vec<u8> { + let mut payload = Vec::new(); + for value in [0.0_f32, 0.0, 0.0, 0.0, 2.0, 0.0, 1.0, 0.0] { + payload.extend_from_slice(&value.to_le_bytes()); + } + for value in [0, 0, 7, 0, 2, 0] { + test_push_u32(&mut payload, value); + } + for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0] { + payload.extend_from_slice(&value.to_le_bytes()); + } + for _ in 0..2 { + test_push_i32(&mut payload, -1); + test_push_i32(&mut payload, -1); + } + test_push_u32(&mut payload, 1); + test_push_u32(&mut payload, 1); + payload.extend_from_slice(&1_u16.to_le_bytes()); + payload.extend_from_slice(&0_u16.to_le_bytes()); + build_nres(&[(12, 1, 0, 0, payload)]) + } + + #[test] + fn full_load_applies_a_synthetic_numeric_formula_in_init() { + fn push_u32(bytes: &mut Vec<u8>, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn push_i32(bytes: &mut Vec<u8>, value: i32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn push_f32(bytes: &mut Vec<u8>, value: f32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) { + push_u32(bytes, u32::try_from(value.len()).expect("length fits u32")); + bytes.extend_from_slice(value); + } + + fn synthetic_mission() -> Vec<u8> { + let mut bytes = Vec::new(); + push_u32(&mut bytes, 1); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, 6); + push_u32(&mut bytes, 1); + push_lp(&mut bytes, b"Synthetic"); + push_u32(&mut bytes, u32::MAX); + push_f32(&mut bytes, 12.25); + push_f32(&mut bytes, 34.75); + push_u32(&mut bytes, 1); + push_lp(&mut bytes, b"MISSIONS/SCRIPTS/formula"); + push_i32(&mut bytes, 0); + push_lp(&mut bytes, b""); + push_i32(&mut bytes, 0); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, 10); + push_u32(&mut bytes, 0); + push_lp(&mut bytes, b"DATA\\MAPS\\SYNTHETIC\\land"); + push_u32(&mut bytes, 0); + push_lp(&mut bytes, b""); + push_u32(&mut bytes, 1); + push_u32(&mut bytes, 0); + bytes + } + + let package = ScriptPackage { + opcode_handler_count: 73, + events: vec![fparkan_script::ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0, + instructions: vec![ + ScriptInstruction { + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], + references: vec![0, 1, 2], + }, + ScriptInstruction { + header_words: [u32::MAX, 3, u32::MAX, u32::MAX, 6, 0, 0], + references: Vec::new(), + }, + ], + }], + trailing_bytes: Vec::new(), + raw: Arc::from(&b""[..]), + }; + let mut files = MemoryVfs::default(); + for (path, bytes) in [ + ( + "MISSIONS/TEST/data.tma", + Arc::<[u8]>::from(synthetic_mission().into_boxed_slice()), + ), + ( + "DATA/MAPS/SYNTHETIC/Land.msh", + Arc::<[u8]>::from(empty_land_mesh().into_boxed_slice()), + ), + ( + "DATA/MAPS/SYNTHETIC/Land.map", + Arc::<[u8]>::from(minimal_land_map().into_boxed_slice()), + ), + ("BuildDat.lst", Arc::from(&b""[..])), + ( + "MISSIONS/SCRIPTS/formula.scr", + Arc::<[u8]>::from(encode_script_package(&package).into_boxed_slice()), + ), + ( + "MISSIONS/SCRIPTS/formula.var", + Arc::from( + &b"VAR(DWORD, ClanBaseX, 0)\nVAR(DWORD, ClanBaseY, 0)\nVAR(DWORD, ClanID, 99)\nVAR(float, target, 0.5)\n"[..], + ), + ), + ( + "MISSIONS/SCRIPTS/formula.fml", + Arc::from( + &b"// authored Init expression\n\nFUNCTION( , ClanID + 0.8, )\nFUNCTION( later, target * 2, )\n"[..], + ), + ), + ] { + files.insert( + normalize_engine_path("synthetic mission", path).expect("valid path"), + bytes, + ); + } + let vfs: Arc<dyn Vfs> = Arc::new(files); + let mut engine = create(EngineServices::new(vfs)).expect("engine"); + let loaded = load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, + ) + .expect("full load applies the authored numeric literal"); + + assert_eq!(loaded.script_bundle_count, 1); + let states = loaded_mission_script_varset_states(&engine).expect("runtime varset"); + assert_eq!( + states[0].values, + [ + VarSetDefault::Dword(12), + VarSetDefault::Dword(35), + VarSetDefault::Dword(0), + VarSetDefault::FloatBits(0x3f4c_cccd), + ] + ); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_fml_variable_rows_resolve_from_shared_varset_defaults() { + fn formula_action(target: u32, row: u32) -> ScriptInstruction { + ScriptInstruction { + header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, row], + references: Vec::new(), + } + } + + fn init_event_index(package: &ScriptPackage) -> usize { + package + .events + .iter() + .position(|event| { + event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]) + }) + .expect("Init event") + } + + fn row_is_assigned(instructions: &[ScriptInstruction], row: u32, target: u32) -> bool { + instructions.iter().any(|instruction| { + instruction.header_words[0] == u32::MAX + && instruction.header_words[1] == target + && instruction.header_words[2] == u32::MAX + && instruction.header_words[3] == u32::MAX + && instruction.header_words[6] == row + }) + } + + let root = licensed_root("IS"); + let script_dir = root.join("MISSIONS/SCRIPTS"); + let declarations = fparkan_script::parse_varset( + &std::fs::read(script_dir.join("varset.var")).expect("read shared varset"), + ) + .expect("parse shared varset"); + let formulas3 = FormulaTable::parse( + &std::fs::read(script_dir.join("c1m3e.fml")).expect("read c1m3e.fml"), + ); + let package3 = fparkan_script::decode( + &std::fs::read(script_dir.join("c1m3e.scr")).expect("read c1m3e.scr"), + ) + .expect("decode c1m3e.scr"); + let init3_index = init_event_index(&package3); + let init3_event_word = package3.events[init3_index].event_word; + let init3 = &package3.events[init3_index].instructions; + assert!(row_is_assigned(init3, 0, 40)); + assert!(row_is_assigned(init3, 1, 41)); + assert!(row_is_assigned(init3, 2, 164)); + assert!(!row_is_assigned(init3, 19, 158)); + assert!(!row_is_assigned(init3, 22, 174)); + assert!(row_is_assigned( + &package3 + .events + .iter() + .find(|event| event.name_raw.split(|byte| *byte == 0).next() + == Some(&b"Fort_Captured"[..])) + .expect("Fort_Captured event") + .instructions, + 19, + 158, + )); + assert!(row_is_assigned( + &package3 + .events + .iter() + .find(|event| event.name_raw.split(|byte| *byte == 0).next() + == Some(&b"PBM_BUILDING_PROTECT_Start"[..])) + .expect("building-protection event") + .instructions, + 22, + 174, + )); + assert_eq!(formulas3.rows[19].expression_raw, b" 0.8 + fT"); + assert_eq!( + formulas3.rows[22].expression_raw, + b" dBuildingProtectHits - dT2" + ); + + let f_temp = u32::try_from( + declarations + .declarations + .iter() + .position(|declaration| declaration.name == "fTemp") + .expect("fTemp declaration"), + ) + .expect("fTemp index fits u32"); + let d_temp3 = u32::try_from( + declarations + .declarations + .iter() + .position(|declaration| declaration.name == "dTemp3") + .expect("dTemp3 declaration"), + ) + .expect("dTemp3 index fits u32"); + assert_eq!((f_temp, d_temp3), (158, 174)); + + let mut synthetic3 = package3.clone(); + synthetic3.events[init3_index].instructions = + vec![formula_action(f_temp, 19), formula_action(d_temp3, 22)]; + assert_eq!( + declarations.resolve_init( + &synthetic3, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas3), + ), + Ok(ScriptInitResolution::Executed { + event_index: init3_index, + event_word: init3_event_word, + writes: vec![ + ScriptInitWrite { + index: f_temp, + value: VarSetDefault::FloatBits(0.8_f32.to_bits()), + }, + ScriptInitWrite { + index: d_temp3, + value: VarSetDefault::Dword(2_000_000), + }, + ], + }) + ); + + let formulas4 = FormulaTable::parse( + &std::fs::read(script_dir.join("c1m4e.fml")).expect("read c1m4e.fml"), + ); + let package4 = fparkan_script::decode( + &std::fs::read(script_dir.join("c1m4e.scr")).expect("read c1m4e.scr"), + ) + .expect("decode c1m4e.scr"); + let init4_index = init_event_index(&package4); + let init4_event_word = package4.events[init4_index].event_word; + let init4 = &package4.events[init4_index].instructions; + assert!(!row_is_assigned(init4, 0, f_temp)); + assert!(row_is_assigned( + &package4 + .events + .iter() + .find(|event| event.name_raw.split(|byte| *byte == 0).next() + == Some(&b"PBM_BUILDING_INF_CAPTURE_Start"[..])) + .expect("building-infantry-capture event") + .instructions, + 0, + f_temp, + )); + assert_eq!(formulas4.rows[0].expression_raw, b" fTemp + 0.00001"); + let mut synthetic4 = package4.clone(); + synthetic4.events[init4_index].instructions = + vec![formula_action(f_temp, 0), formula_action(f_temp, 0)]; + assert_eq!( + declarations.resolve_init( + &synthetic4, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas4), + ), + Ok(ScriptInitResolution::Executed { + event_index: init4_index, + event_word: init4_event_word, + writes: vec![ + ScriptInitWrite { + index: f_temp, + value: VarSetDefault::FloatBits(0.00001_f32.to_bits()), + }, + ScriptInitWrite { + index: f_temp, + value: VarSetDefault::FloatBits((0.00001_f32 + 0.00001_f32).to_bits()), + }, + ], + }) + ); + } + #[test] fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() { let mut personal_files = MemoryVfs::default(); @@ -12257,6 +14994,27 @@ mod tests { } #[test] + fn script_formula_table_uses_an_optional_case_insensitive_scr_sibling() { + let mut files = MemoryVfs::default(); + let vfs: Arc<dyn Vfs> = Arc::new(files.clone()); + assert!(matches!( + load_script_formula_table(&vfs, "MISSIONS/SCRIPTS/custom.SCR"), + Ok(None) + )); + + files.insert( + normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.fml").expect("path"), + Arc::from(&b"FUNCTION(value, 1.25, )\n"[..]), + ); + let vfs: Arc<dyn Vfs> = Arc::new(files); + let formulas = load_script_formula_table(&vfs, "MISSIONS/SCRIPTS/custom.SCR") + .expect("load optional formula sibling") + .expect("formula file exists"); + assert_eq!(formulas.rows.len(), 1); + assert_eq!(formulas.evaluate_numeric_literal(0), Ok(1.25)); + } + + #[test] fn handler_nineteen_init_executes_from_clan_anchor_for_each_bundle() { let declarations = fparkan_script::parse_varset( b"VAR( DWORD, ClanBaseX, 950)\nVAR( DWORD, ClanBaseY, 1000)\nVAR( DWORD, ClanID, 0)\n", @@ -12278,18 +15036,19 @@ mod tests { name_raw: b"Init\0".to_vec(), event_word: 0x1234_5678, instructions: vec![fparkan_script::ScriptInstruction { - header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], references: vec![0, 1, 2], }], }], trailing_bytes: Vec::new(), raw: Arc::from(&b""[..]), }, + formula_table: None, init_execution: MissionScriptInitExecution::Missing, behavior_warm_start: None, }]; - let states = resolve_handler19_init_states( + let states = resolve_init_states( &[[500.0, 752.0], [728.0, 449.0]], &mut bundles, &varset, @@ -12303,15 +15062,18 @@ mod tests { event_index: 0, event_word: 0x1234_5678, writes: vec![ - Handler19DwordWrite { + ScriptInitWrite { index: 0, - value: 728 + value: VarSetDefault::Dword(728), }, - Handler19DwordWrite { + ScriptInitWrite { index: 1, - value: 449 + value: VarSetDefault::Dword(449), + }, + ScriptInitWrite { + index: 2, + value: VarSetDefault::Dword(1), }, - Handler19DwordWrite { index: 2, value: 1 }, ], }] ); @@ -12349,6 +15111,8 @@ mod tests { #[test] fn handler_nineteen_preview_keeps_unsupported_package_without_partial_writes() { + let formula_table = + FormulaTable::parse(b"FUNCTION(add, x + 2, )\nFUNCTION(unsupported, x * 2, )\n"); let varset = MissionScriptVarSet { path: FALLBACK_SCRIPT_VARSET_PATH.to_string(), used_fallback: true, @@ -12365,11 +15129,15 @@ mod tests { event_word: 0x1234, instructions: vec![ fparkan_script::ScriptInstruction { - header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], references: vec![0, 1, 2], }, fparkan_script::ScriptInstruction { - header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0], + header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 0], + references: Vec::new(), + }, + fparkan_script::ScriptInstruction { + header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 1], references: Vec::new(), }, ], @@ -12382,12 +15150,13 @@ mod tests { base_path: "MISSIONS/SCRIPTS/default".to_string(), script_path: "MISSIONS/SCRIPTS/default.scr".to_string(), package: package.clone(), + formula_table: Some(formula_table.clone()), init_execution: MissionScriptInitExecution::Missing, behavior_warm_start: None, }; let anchors = [[500.0, 752.0]]; let mut preview_bundles = vec![bundle()]; - let preview_states = resolve_handler19_init_states( + let preview_states = resolve_init_states( &anchors, &mut preview_bundles, &varset, @@ -12399,9 +15168,10 @@ mod tests { preview_bundles[0].init_execution, MissionScriptInitExecution::Unsupported { event_index: 0, - instruction_index: 1, - error: Handler19ResolveError::UnsupportedSelector { - found: ScriptDispatchSelector::Handler(18), + instruction_index: 2, + error: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 1, + expression: b" x * 2".to_vec(), }, } ); @@ -12421,7 +15191,7 @@ mod tests { let mut full_bundles = vec![bundle()]; assert!(matches!( - resolve_handler19_init_states( + resolve_init_states( &anchors, &mut full_bundles, &varset, @@ -12430,12 +15200,13 @@ mod tests { Err(EngineError::ScriptInitUnsupported { clan_index: 0, event_index: 0, - instruction_index: 1, - source: Handler19ResolveError::UnsupportedSelector { - found: ScriptDispatchSelector::Handler(18), + instruction_index: 2, + source: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 1, + expression, }, .. - }) + }) if expression == b" x * 2" )); let mut malformed_package = package.clone(); @@ -12447,7 +15218,7 @@ mod tests { ..bundle() }]; assert!(matches!( - resolve_handler19_init_states( + resolve_init_states( &anchors, &mut malformed_bundle, &varset, @@ -12639,7 +15410,10 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::AutoDemo, }, - 100_000, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, ) .expect("load licensed AutoDemo"); @@ -12779,11 +15553,24 @@ mod tests { assert_eq!(loaded.script_bundle_count, 2); assert_eq!(loaded.script_init_state_count, 2); assert_eq!(loaded.script_varset_state_count, 2); - assert!(loaded_mission_script_bundles(&engine) - .expect("script bundles") - .iter() - .all(|bundle| bundle.init_execution - == MissionScriptInitExecution::Executed { event_index: 0 })); + let script_bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); + assert_eq!(script_bundles.len(), 2); + assert!(script_bundles.iter().all(|bundle| bundle.init_execution + == MissionScriptInitExecution::Executed { event_index: 0 })); + for bundle in script_bundles { + let init = bundle + .package + .events + .iter() + .find(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])) + .expect("first Init event"); + let instruction = init.instructions.first().expect("Handler(19) action"); + assert_eq!( + instruction.header_words, + [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX] + ); + assert_eq!(instruction.references.as_slice(), &[224, 225, 226]); + } assert_eq!(engine.behavior_rng_state, 0xc823_f683); assert_eq!( loaded_mission_script_bundles(&engine) @@ -12837,7 +15624,10 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".into(), mode: MissionMode::AutoDemo, }, - 100_000, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, ) .unwrap(); let tables = loaded_mission_control_device_tables(&engine).unwrap(); @@ -12858,6 +15648,334 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] + fn full_load_builds_initial_behavior_control_groupings_for_autodemo() { + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( + licensed_root("IS"), + )))) + .expect("engine"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".into(), + mode: MissionMode::AutoDemo, + }, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("load licensed AutoDemo with explicit startup inputs"); + + let groupings = + loaded_mission_behavior_control_groupings(&engine).expect("initial Behavior groupings"); + let group = |header_device_ordinal, candidate_device_ordinals: &[usize]| { + MissionBehaviorControlGroup { + header_device_ordinal, + candidate_device_ordinals: candidate_device_ordinals.to_vec(), + tag30_device_ordinals: Vec::new(), + } + }; + let expected = [ + (0, vec![group(6, &[10, 11, 12, 13])]), + (1, vec![group(6, &[10, 11])]), + (2, vec![group(8, &[12, 13, 14])]), + (3, vec![group(6, &[10, 11, 12])]), + (4, Vec::new()), + (5, Vec::new()), + (6, Vec::new()), + (7, Vec::new()), + ]; + assert_eq!(groupings.len(), expected.len()); + for (original_id, expected_groups) in expected { + let state = groupings + .iter() + .find(|state| state.original_id == Some(OriginalObjectId(original_id))) + .expect("grouping for AutoDemo Control object"); + assert_eq!( + state.grouping, + Ok(MissionBehaviorControlGrouping { + groups: expected_groups, + ungrouped_candidate_device_ordinals: Vec::new(), + }), + "original object {original_id}" + ); + } + } + + #[test] + #[ignore = "requires licensed corpus"] + fn full_load_queries_control_selector_15_against_frozen_native_inputs() { + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( + licensed_root("IS"), + )))) + .expect("engine"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".into(), + mode: MissionMode::AutoDemo, + }, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("load licensed AutoDemo with explicit startup inputs"); + + let tables = loaded_mission_control_device_tables(&engine).expect("Control tables"); + let expected = [ + (0, Some(0x4657_2800)), + (1, Some(0x4636_d000)), + (2, Some(0x467b_4001)), + (3, Some(0x468c_a000)), + (4, Some(0x46ec_4001)), + (5, Some(0x46ec_4001)), + (6, None), + (7, None), + ]; + assert_eq!(tables.len(), expected.len()); + for (original_id, expected_bits) in expected { + let table = tables + .iter() + .find(|table| table.original_id == Some(OriginalObjectId(original_id))) + .expect("AutoDemo Control table for original object"); + let actual = query_loaded_mission_control_resource(&engine, table.handle, 15) + .expect("selector 15 query") + .map(f32::to_bits); + assert_eq!(actual, expected_bits, "original object {original_id}"); + } + } + + #[test] + #[ignore = "requires licensed corpus"] + fn full_load_queries_indexed_control_selector_400_for_all_autodemo_devices() { + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( + licensed_root("IS"), + )))) + .expect("engine"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".into(), + mode: MissionMode::AutoDemo, + }, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("load licensed AutoDemo with explicit startup inputs"); + + let tables = loaded_mission_control_device_tables(&engine).expect("Control tables"); + let expected: [(u32, usize, &[u32]); 8] = [ + (0, 33, &[0, 0, 0, 0, 0, 0, 16, 19, 16, 18, 27, 30, 31, 32]), + (1, 23, &[0, 0, 0, 0, 0, 0, 10, 13, 10, 11, 19, 22]), + (2, 26, &[0, 0, 0, 0, 0, 0, 0, 0, 8, 9, 8, 10, 19, 22, 25]), + (3, 29, &[0, 0, 0, 0, 0, 0, 16, 21, 16, 20, 26, 27, 28]), + (4, 32, &[5, 5, 1, 23, 3, 22, 7, 19, 16, 23, 5, 29]), + (5, 32, &[5, 5, 1, 23, 3, 22, 7, 19, 16, 23, 5, 29]), + (6, 22, &[17, 19, 19, 15, 13, 14, 10, 11, 4, 5, 7]), + (7, 22, &[17, 19, 19, 15, 13, 14, 10, 11, 4, 5, 7]), + ]; + assert_eq!(tables.len(), expected.len()); + + let mut queried_devices = 0; + for (original_id, expected_provider_rows, expected_keys) in expected { + let table = tables + .iter() + .find(|table| table.original_id == Some(OriginalObjectId(original_id))) + .expect("AutoDemo Control table for original object"); + let devices = match &table.status { + MissionControlDeviceTableStatus::Available(devices) => devices, + MissionControlDeviceTableStatus::Unavailable(reason) => { + panic!("Control table for object {original_id} is unavailable: {reason:?}"); + } + }; + let provider_rows = match &table.node_provider_status { + MissionControlNodeProviderTableStatus::Available(rows) => rows, + MissionControlNodeProviderTableStatus::Unavailable(reason) => { + panic!("provider rows for object {original_id} are unavailable: {reason:?}"); + } + }; + assert_eq!( + devices.len(), + expected_keys.len(), + "device count for {original_id}" + ); + assert_eq!( + provider_rows.len(), + expected_provider_rows, + "provider row count for {original_id}" + ); + + for (device_index, expected_key) in expected_keys.iter().copied().enumerate() { + let device = &devices[device_index]; + assert_eq!( + device.key, expected_key, + "provider key for object {original_id}, device {device_index}" + ); + assert!( + (expected_key as usize) < provider_rows.len(), + "provider key for object {original_id}, device {device_index}" + ); + let actual = query_loaded_mission_control_device_selector( + &engine, + table.handle, + device_index, + 0x400, + ) + .expect("selector 0x400 query"); + assert_eq!( + actual.map(f32::to_bits), + Some(0x3f80_0000), + "current provider value for object {original_id}, device {device_index}, key {expected_key}" + ); + queried_devices += 1; + } + } + assert_eq!(queried_devices, 100); + } + + #[test] + #[ignore = "requires licensed AutoDemo corpus"] + fn licensed_autodemo_queries_selector_6_for_all_effective_factory2_pairs() { + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( + licensed_root("IS"), + )))) + .expect("engine"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".into(), + mode: MissionMode::AutoDemo, + }, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .unwrap_or_else(|error| { + panic!("full AutoDemo load failed before selector-6 queries: {error:?}") + }); + + let expected_vectors = [ + ("bb_m_01", 0x43f9_8000), + ("bm_m_01", 0x4422_8000), + ("bl_l_01", 0x436a_0000), + ("br_b_01", 0x44c8_0000), + ("bf_b_01", 0x443b_8000), + ("bl_m_01", 0x443b_4000), + ("bm_l_01", 0x4334_0000), + ]; + let tables = loaded_mission_control_device_tables(&engine).expect("Control tables"); + let mut observed = Vec::new(); + for table in tables { + let devices = match &table.status { + MissionControlDeviceTableStatus::Available(devices) => devices, + MissionControlDeviceTableStatus::Unavailable(reason) => { + panic!( + "Control table for object {:?} is unavailable before selector-6 census: {reason:?}", + table.original_id + ); + } + }; + for (device_index, device) in devices.iter().enumerate() { + if device.descriptor.tag != 2 || device.factory_tag != 2 { + continue; + } + let pair = factory2_pair_from_record(&device.descriptor); + let result = query_loaded_mission_control_device_selector( + &engine, + table.handle, + device_index, + 6, + ); + observed.push((table.original_id, device_index, pair, result)); + } + } + + let mut observed_pairs = Vec::new(); + let mut mismatches = Vec::new(); + for (original_id, device_index, pair, result) in &observed { + let pair_label = pair + .as_ref() + .map(|pair| format!("{}:{}", pair.archive, pair.name)) + .unwrap_or_else(|| "<unparsed>".to_string()); + match result { + Ok(Some(value)) => println!( + "selector6 object={original_id:?} device={device_index} pair={pair_label} result=Some({:08x})", + value.to_bits() + ), + Ok(None) => println!( + "selector6 object={original_id:?} device={device_index} pair={pair_label} result=None" + ), + Err(error) => println!( + "selector6 object={original_id:?} device={device_index} pair={pair_label} error={error:?}" + ), + } + + let Some(pair) = pair else { + mismatches.push(format!( + "object {original_id:?} device {device_index}: malformed factory-2 pair" + )); + continue; + }; + if !pair.archive.eq_ignore_ascii_case("objects.rlb") { + mismatches.push(format!( + "object {original_id:?} device {device_index}: unexpected archive {}", + pair.archive + )); + continue; + } + let name = pair.name.to_ascii_lowercase(); + if !observed_pairs.contains(&name) { + observed_pairs.push(name.clone()); + } + let expected = expected_vectors + .iter() + .find(|(expected_name, _)| name.as_str() == *expected_name) + .map(|(_, bits)| *bits); + match (result, expected) { + (Ok(Some(value)), Some(expected_bits)) if value.to_bits() == expected_bits => {} + (Ok(Some(value)), Some(expected_bits)) => mismatches.push(format!( + "object {original_id:?} device {device_index} pair {name}: expected {expected_bits:08x}, got {:08x}", + value.to_bits() + )), + (Ok(None), Some(_)) => mismatches.push(format!( + "object {original_id:?} device {device_index} pair {name}: native selector returned None" + )), + (Err(error), Some(_)) => mismatches.push(format!( + "object {original_id:?} device {device_index} pair {name}: {error:?}" + )), + (_, None) => mismatches.push(format!( + "object {original_id:?} device {device_index}: pair {name} is not in the frozen native vectors" + )), + } + } + + observed_pairs.sort(); + let mut expected_pairs = expected_vectors + .iter() + .map(|(name, _)| (*name).to_string()) + .collect::<Vec<_>>(); + expected_pairs.sort(); + println!( + "selector6 census: effective_devices={}, distinct_pairs={}, pairs={observed_pairs:?}", + observed.len(), + observed_pairs.len() + ); + assert_eq!( + observed_pairs, expected_pairs, + "actual effective AutoDemo factory-2 pair census differs from the frozen vector set" + ); + assert!( + mismatches.is_empty(), + "selector-6 mismatches: {mismatches:#?}" + ); + } + + #[test] + #[ignore = "requires licensed corpus"] fn headless_step_runs_resource_schedule_and_rolls_back_both_error_paths() { let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( licensed_root("IS"), @@ -12869,7 +15987,10 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".into(), mode: MissionMode::AutoDemo, }, - 100_000, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, ) .expect("licensed mission"); let schedules = |engine: &Engine| { @@ -12978,7 +16099,10 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::AutoDemo, }, - 100_000, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, ) .expect("load licensed AutoDemo"); @@ -13713,16 +16837,17 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] - fn licensed_campaign_preview_keeps_unsupported_init_without_partial_writes() { + fn licensed_campaign_full_load_applies_literals_then_single_handler43_rolls_back() { let root = licensed_root("IS"); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); - let mut engine = create(EngineServices::new(vfs)).expect("engine"); + let mut engine = create(EngineServices::new(vfs.clone())).expect("engine"); + let campaign_request = || MissionRequest { + key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), + mode: MissionMode::SingleMission, + }; let loaded = load_mission_static_preview_roots( &mut engine, - MissionRequest { - key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), - mode: MissionMode::SingleMission, - }, + campaign_request(), NonZeroUsize::new(1).expect("non-zero root count"), ) .expect("load licensed campaign preview"); @@ -13741,32 +16866,310 @@ mod tests { .iter() .all(|bundle| bundle.behavior_warm_start.is_none())); assert_eq!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE); - let unsupported = bundles + let bundle = bundles .iter() - .find(|bundle| { - matches!( - &bundle.init_execution, - MissionScriptInitExecution::Unsupported { .. } - ) - }) - .expect("campaign package with an unsupported Init instruction"); - let clan_index = unsupported.clan_index; + .find(|bundle| bundle.clan_index == 0) + .expect("first campaign script bundle"); + assert_eq!( + bundle.base_path.replace('\\', "/"), + "MISSIONS/SCRIPTS/tut1_pl2" + ); + assert_eq!(bundle.formula_table.as_ref().unwrap().rows.len(), 26); + assert_eq!( + bundle + .formula_table + .as_ref() + .unwrap() + .evaluate_numeric_literal(0), + Ok(0.0) + ); + assert_eq!( + bundle.init_execution, + MissionScriptInitExecution::Executed { event_index: 0 } + ); let states = loaded_mission_script_init_states(&engine).expect("Init writes"); - assert!(states.iter().all(|state| state.clan_index != clan_index)); + let init = states + .iter() + .find(|state| state.clan_index == 0) + .expect("first clan Init writes"); + assert_eq!( + init.writes + .iter() + .map(|write| write.index) + .collect::<Vec<_>>(), + [224, 225, 226, 188, 189, 190, 191] + ); + assert!(init.writes[3..] + .iter() + .all(|write| write.value == VarSetDefault::Dword(0))); let varset = loaded_mission_script_varset_states(&engine) .expect("per-clan varsets") .iter() - .find(|state| state.clan_index == clan_index) - .expect("unsupported clan varset"); + .find(|state| state.clan_index == 0) + .expect("first clan varset"); + assert_eq!( + &varset.values[188..192], + &[ + VarSetDefault::Dword(0), + VarSetDefault::Dword(0), + VarSetDefault::Dword(0), + VarSetDefault::Dword(0), + ] + ); assert_eq!( &varset.values[224..227], &[ - VarSetDefault::Dword(950), - VarSetDefault::Dword(1000), + VarSetDefault::Dword(549), + VarSetDefault::Dword(630), VarSetDefault::Dword(0), ] ); assert!(loaded.script_bundle_count >= 1); + + let full = load_mission( + &mut engine, + campaign_request(), + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("full load licensed campaign mission"); + assert_eq!(full.script_bundle_count, 4); + assert_eq!(full.script_init_state_count, 4); + assert_eq!(full.script_varset_state_count, 4); + let full_bundles = loaded_mission_script_bundles(&engine).expect("full script bundles"); + assert_eq!(full_bundles.len(), 4); + assert!(full_bundles.iter().all(|bundle| matches!( + &bundle.init_execution, + MissionScriptInitExecution::Executed { event_index: 0 } + ))); + assert!(full_bundles + .iter() + .all(|bundle| bundle.behavior_warm_start.is_some())); + let full_states = loaded_mission_script_init_states(&engine).expect("full Init writes"); + let first_full_init = full_states + .iter() + .find(|state| state.clan_index == 0) + .expect("campaign clan 0 Init"); + assert_eq!( + first_full_init + .writes + .iter() + .map(|write| write.index) + .collect::<Vec<_>>(), + [224, 225, 226, 188, 189, 190, 191] + ); + assert!(first_full_init.writes[3..] + .iter() + .all(|write| write.value == VarSetDefault::Dword(0))); + let campaign_summary = loaded_mission(&engine).expect("full campaign").clone(); + let campaign_bundles = full_bundles.to_vec(); + let campaign_init_states = full_states.to_vec(); + let campaign_varsets = loaded_mission_script_varset_states(&engine) + .expect("full per-clan varsets") + .to_vec(); + let campaign_rng = engine.behavior_rng_state; + let before = step_headless(&mut engine, InputSnapshot, 0) + .expect("step loaded campaign") + .snapshot + .objects; + + let single_error = load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Single.01/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + MissionStartupInput { + queue_time_ms: 100_500, + research_creation_gate: false, + }, + ) + .expect_err("Single.01 reaches the later Handler(43) boundary"); + assert!(matches!( + single_error, + EngineError::ScriptInitUnsupported { + clan_index: 1, + path, + event_index: 0, + instruction_index: 10, + source: ScriptInitResolveError::UnsupportedSelector { + found: fparkan_script::ScriptDispatchSelector::Handler(43), + }, + } if path.ends_with("scream.scr") + )); + assert_eq!(loaded_mission(&engine), Some(&campaign_summary)); + assert_eq!( + loaded_mission_script_bundles(&engine), + Some(campaign_bundles.as_slice()) + ); + assert_eq!( + loaded_mission_script_init_states(&engine), + Some(campaign_init_states.as_slice()) + ); + assert_eq!( + loaded_mission_script_varset_states(&engine), + Some(campaign_varsets.as_slice()) + ); + assert_eq!(engine.behavior_rng_state, campaign_rng); + let after = step_headless(&mut engine, InputSnapshot, 0) + .expect("step retained campaign") + .snapshot + .objects; + assert_eq!(before, after); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_single_script_bundle_load_initializes_ai_slots_before_later_init_work() { + let root = licensed_root("IS"); + let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); + let path = normalize_engine_path("mission", "MISSIONS/Single.01/data.tma") + .expect("Single.01 path"); + let mission = decode_mission_payload( + read_vfs(&vfs, &path).expect("Single.01 TMA"), + TmaProfile::Strict, + ) + .expect("decode Single.01 TMA"); + assert_eq!(script_slots_test_tags(&mission), [9, 9]); + + let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full) + .expect("Full script bundle load"); + assert_eq!( + full.ai_startup_states + .iter() + .map(|state| state.record_slots.len()) + .collect::<Vec<_>>(), + [9, 9] + ); + assert_eq!( + full.ai_startup_states + .iter() + .map(|state| state.clan_index) + .collect::<Vec<_>>(), + [0, 1] + ); + assert!(full + .ai_startup_states + .iter() + .all(|state| { state.record_slots == [u32::MAX; 9] })); + assert!(full + .bundles + .iter() + .all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing)); + + let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview) + .expect("preview script bundle load"); + assert!(preview.ai_startup_states.is_empty()); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_single_preview_loads_each_formula_table_and_stops_at_handler43() { + let root = licensed_root("IS"); + let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); + let mut engine = create(EngineServices::new(vfs)).expect("engine"); + load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: "MISSIONS/Single.01/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + NonZeroUsize::new(1).expect("non-zero root count"), + ) + .expect("load licensed Single.01 preview"); + + assert!(loaded_mission_behavior_control_groupings(&engine) + .expect("preview grouping projection") + .is_empty()); + + let bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); + assert_eq!(bundles.len(), 2); + let first = bundles + .iter() + .find(|bundle| bundle.clan_index == 0) + .expect("scr_pl_1 bundle"); + assert_eq!( + first.base_path.replace('\\', "/"), + "MISSIONS/SCRIPTS/scr_pl_1" + ); + assert_eq!(first.formula_table.as_ref().unwrap().rows.len(), 5); + assert_eq!( + first.init_execution, + MissionScriptInitExecution::Executed { event_index: 0 } + ); + let second = bundles + .iter() + .find(|bundle| bundle.clan_index == 1) + .expect("scream bundle"); + assert_eq!( + second.base_path.replace('\\', "/"), + "MISSIONS/SCRIPTS/scream" + ); + let formulas = second.formula_table.as_ref().expect("scream FML"); + assert_eq!(formulas.rows.len(), 149); + for (index, expected_bits) in [ + 0x3ecc_cccd, + 0x3f4c_cccd, + 0x3f00_0000, + 0x3f80_0000, + 0x4120_0000, + 0x3e4c_cccd, + 0x42c8_0000, + 0x43c8_0000, + 0x4000_0000, + ] + .into_iter() + .enumerate() + { + assert_eq!( + formulas + .evaluate_numeric_literal(index as u32) + .expect("referenced native literal") + .to_bits(), + expected_bits + ); + } + assert_eq!( + second.init_execution, + MissionScriptInitExecution::Unsupported { + event_index: 0, + instruction_index: 10, + error: ScriptInitResolveError::UnsupportedSelector { + found: fparkan_script::ScriptDispatchSelector::Handler(43), + }, + } + ); + + let states = loaded_mission_script_init_states(&engine).expect("preview Init writes"); + assert!(states.iter().any(|state| state.clan_index == 0)); + assert!(states.iter().all(|state| state.clan_index != 1)); + let first_varset = loaded_mission_script_varset_states(&engine) + .expect("per-clan varsets") + .iter() + .find(|state| state.clan_index == 0) + .expect("scr_pl_1 varset"); + assert_eq!( + &first_varset.values[186..188], + &[VarSetDefault::Dword(0), VarSetDefault::Dword(0)] + ); + let declarations = loaded_mission_script_varset(&engine) + .expect("shared script varset") + .declarations + .declarations + .iter() + .map(|declaration| declaration.default_value) + .collect::<Vec<_>>(); + let second_varset = loaded_mission_script_varset_states(&engine) + .expect("per-clan varsets") + .iter() + .find(|state| state.clan_index == 1) + .expect("scream varset"); + assert_eq!(second_varset.values, declarations); + assert!(bundles + .iter() + .all(|bundle| bundle.behavior_warm_start.is_none())); } #[test] @@ -13800,8 +17203,15 @@ mod tests { .snapshot .objects; - let error = load_mission(&mut engine, request(), 0) - .expect_err("malformed profile archive prevents full mission load"); + let error = load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, + ) + .expect_err("malformed profile archive prevents full mission load"); assert!(matches!( error, EngineError::Nres { ref path, .. } if path == "behpsp.res" @@ -13930,7 +17340,10 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::SingleMission, }, - 0, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, ) .expect("load mission"); @@ -14003,7 +17416,15 @@ mod tests { }); let mut engine = create(EngineServices::new(vfs)).expect("engine"); let before = step_headless(&mut engine, InputSnapshot, 0).expect("before"); - let err = load_mission(&mut engine, mission, 0).expect_err("load error"); + let err = load_mission( + &mut engine, + mission, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, + ) + .expect_err("load error"); match denied { DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })), DenyRule::Suffix("objects.rlb") => { @@ -14035,7 +17456,10 @@ mod tests { fail_after_registered_objects: Some(1), ..MissionLoadOptions::default() }, - Some(0), + Some(MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }), ) .expect_err("forced registration failure"); @@ -14064,16 +17488,31 @@ mod tests { .iter() .position(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])) .expect("Init event"); - assert_eq!( - package.events[init_index].instructions[0].header_words[0], - 19 + let init = &mut package.events[init_index]; + let handler = init + .instructions + .first() + .expect("original Handler(19) instruction"); + assert_eq!(handler.header_words[0], 19); + assert_eq!(handler.references.as_slice(), &[224, 225, 226]); + let original_instruction_count = init.instructions.len(); + init.instructions.push(ScriptInstruction { + header_words: [u32::MAX, 164, u32::MAX, u32::MAX, 6, 0, 0], + references: Vec::new(), + }); + init.instructions.push(ScriptInstruction { + header_words: [u32::MAX, 158, u32::MAX, u32::MAX, 6, 0, 1], + references: Vec::new(), + }); + let replacement_script = Arc::<[u8]>::from(encode_script_package(&package)); + let replacement_formula = Arc::<[u8]>::from( + &b"FUNCTION(add, ClanBaseX + 0.8, )\nFUNCTION(unsupported, fT * 2, )\n"[..], ); - package.events[init_index].instructions[0].header_words[0] = 18; - let replacement = Arc::<[u8]>::from(encode_script_package(&package)); let vfs = Arc::new(SwappingScriptVfs { inner: DirectoryVfs::new(&root), - replacement, + replacement_script, + replacement_formula, enabled: std::sync::atomic::AtomicBool::new(false), }); let services_vfs: Arc<dyn Vfs> = vfs.clone(); @@ -14082,7 +17521,15 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::AutoDemo, }; - load_mission(&mut engine, mission(), 100_000).expect("initial full startup"); + load_mission( + &mut engine, + mission(), + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("initial full startup"); let previous_loaded = loaded_mission(&engine).expect("previous mission").clone(); let previous_bundles = loaded_mission_script_bundles(&engine) @@ -14098,16 +17545,25 @@ mod tests { .objects; vfs.enabled.store(true, std::sync::atomic::Ordering::SeqCst); - let error = load_mission(&mut engine, mission(), 100_500) - .expect_err("unsupported Init must fail after new-world registration"); + let error = load_mission( + &mut engine, + mission(), + MissionStartupInput { + queue_time_ms: 100_500, + research_creation_gate: false, + }, + ) + .expect_err("unsupported Init must fail after new-world registration"); assert!(matches!( error, EngineError::ScriptInitUnsupported { - source: Handler19ResolveError::UnsupportedSelector { - found: ScriptDispatchSelector::Handler(18), + instruction_index, + source: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 1, + expression, }, .. - } + } if instruction_index == original_instruction_count + 1 && expression == b" fT * 2" )); assert_eq!(loaded_mission(&engine), Some(&previous_loaded)); assert_eq!( @@ -14176,7 +17632,10 @@ mod tests { key: case.1.to_string(), mode: MissionMode::SingleMission, }, - 0, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, ) .expect("load mission"); let drafts = loaded_mission_object_drafts(&engine).expect("object drafts"); @@ -14290,7 +17749,10 @@ mod tests { key: case.mission.to_string(), mode: MissionMode::SingleMission, }, - 0, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, ) .expect("load selected mission"); assert_eq!(loaded.object_count, case.object_count); @@ -14320,7 +17782,10 @@ mod tests { key: mission, mode: MissionMode::SingleMission, }, - 0, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, ) .expect("load mission foundation"); assert_eq!(loaded.object_count, loaded.registered_objects); @@ -14460,6 +17925,516 @@ mod tests { bytes } + fn research_test_trf() -> Vec<u8> { + let mut row = [0_u32; 10]; + row[0] = 0x1122_3344; + row[1] = 0x5566_7788; + row[8] = 0x09ff_0000; + let mut row_bytes = Vec::with_capacity(40); + for word in row { + row_bytes.extend_from_slice(&word.to_le_bytes()); + } + let mut alias = Vec::new(); + alias.extend_from_slice(&0_u16.to_le_bytes()); + alias.extend_from_slice(&0_u16.to_le_bytes()); + let zero_group = 0_u32.to_le_bytes().to_vec(); + build_nres(&[ + (u32::from_le_bytes(*b"TRF0"), 1, 3, 0, row_bytes), + (u32::from_le_bytes(*b"TRF1"), 1, 0, 0, vec![0x5a]), + (u32::from_le_bytes(*b"TRF2"), 1, 0, 0, zero_group.clone()), + (u32::from_le_bytes(*b"TRF4"), 1, 0, 0, zero_group), + (u32::from_le_bytes(*b"TRF6"), 0, 0, 0, b"test\0".to_vec()), + (u32::from_le_bytes(*b"TRF7"), 0, 0, 0, Vec::new()), + (u32::from_le_bytes(*b"TRF8"), 0, 0, 0, Vec::new()), + (u32::from_le_bytes(*b"TRF9"), 0, 0, 0, Vec::new()), + (u32::from_le_bytes(*b"TRFA"), 0, 0, 0, Vec::new()), + (u32::from_le_bytes(*b"TRFB"), 1, 0, 0, alias), + ]) + } + + fn research_startup_test_vfs( + script_bases: &[Option<&[u8]>], + research_paths: &[&[u8]], + trf: Option<&[u8]>, + ) -> Arc<dyn Vfs> { + research_startup_test_vfs_with_raw_tag(script_bases, research_paths, 9, trf) + } + + fn research_startup_test_vfs_with_raw_tag( + script_bases: &[Option<&[u8]>], + research_paths: &[&[u8]], + raw_tag: i32, + trf: Option<&[u8]>, + ) -> Arc<dyn Vfs> { + fn push_u32(bytes: &mut Vec<u8>, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) { + push_u32( + bytes, + u32::try_from(value.len()).expect("string length fits u32"), + ); + bytes.extend_from_slice(value); + } + assert_eq!(script_bases.len(), research_paths.len()); + let mut mission = Vec::new(); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + push_u32(&mut mission, 6); + push_u32(&mut mission, script_bases.len() as u32); + for (index, (script_base, research_path)) in + script_bases.iter().zip(research_paths).enumerate() + { + push_lp(&mut mission, b"Synthetic clan"); + push_u32(&mut mission, u32::MAX); + mission.extend_from_slice(&(index as f32).to_le_bytes()); + mission.extend_from_slice(&0.0_f32.to_le_bytes()); + push_u32(&mut mission, 1); + push_lp(&mut mission, script_base.unwrap_or_default()); + push_u32(&mut mission, 0); + push_lp(&mut mission, research_path); + push_u32(&mut mission, raw_tag as u32); + push_u32(&mut mission, 0); + } + push_u32(&mut mission, 10); + push_u32(&mut mission, 0); + push_lp(&mut mission, b"DATA\\MAPS\\SYNTHETIC\\land"); + push_u32(&mut mission, 0); + push_lp(&mut mission, b""); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + + let land_mesh = empty_land_mesh(); + let land_map = minimal_land_map(); + + let script_package = encode_script_package(&ScriptPackage { + opcode_handler_count: 73, + events: Vec::new(), + trailing_bytes: Vec::new(), + raw: Arc::from(&b""[..]), + }); + let mut files = MemoryVfs::default(); + for (path, bytes) in [ + ("MISSIONS/TEST/data.tma", mission), + ("DATA/MAPS/SYNTHETIC/Land.msh", land_mesh), + ("DATA/MAPS/SYNTHETIC/Land.map", land_map), + ] { + files.insert( + normalize_engine_path("research startup test", path).expect("test path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + files.insert( + normalize_engine_path("test BuildDat", "BuildDat.lst").expect("BuildDat path"), + Arc::from(&b""[..]), + ); + if script_bases.iter().any(Option::is_some) { + files.insert( + normalize_engine_path("test script", "MISSIONS/SCRIPTS/shared.scr") + .expect("script path"), + Arc::from(script_package.into_boxed_slice()), + ); + files.insert( + normalize_engine_path("test varset", "MISSIONS/SCRIPTS/shared.var") + .expect("varset path"), + Arc::from(&b"VAR(DWORD, value, 0)\n"[..]), + ); + } + if let Some(trf) = trf { + files.insert( + normalize_engine_path("test TRF", "DATA/Research/shared.trf").expect("TRF path"), + Arc::from(trf), + ); + } + Arc::new(files) + } + + #[test] + fn ai_record_slot_budget_error_keeps_loaded_mission_clock_and_rng() { + let valid_vfs = research_startup_test_vfs_with_raw_tag( + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[b"", b""], + 9, + None, + ); + let mut engine = create(EngineServices::new(valid_vfs)).expect("startup test engine"); + let request = || MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }; + load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("initial valid full load"); + let previous_mission = loaded_mission(&engine).expect("loaded mission").clone(); + let previous_bundles = loaded_mission_script_bundles(&engine) + .expect("loaded script bundles") + .to_vec(); + let previous_ai_startup_states = loaded_mission_ai_startup_states(&engine) + .expect("loaded AI startup states") + .to_vec(); + assert_eq!(previous_ai_startup_states.len(), 2); + assert_eq!( + previous_ai_startup_states + .iter() + .map(|state| state.clan_index) + .collect::<Vec<_>>(), + [0, 1] + ); + assert!(previous_ai_startup_states + .iter() + .all(|state| state.record_slots == [u32::MAX; 9])); + assert_eq!(previous_bundles.len(), 1); + assert_eq!(previous_bundles[0].clan_index, 0); + assert_eq!( + previous_bundles[0].behavior_warm_start, + Some(BehaviorWarmStartState { + first_time_ms: 100_000, + next_deadline_ms: 100_202, + cleared_words: [0; 5], + }) + ); + let previous_rng = engine.behavior_rng_state; + assert_ne!(previous_rng, INITIAL_BEHAVIOR_RNG_STATE); + let previous_world = fparkan_world::canonical_state_hash(&engine.world); + + engine.services.vfs = Some(research_startup_test_vfs_with_raw_tag( + &[None, None], + &[b"", b""], + -1, + None, + )); + let error = load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_500, + research_creation_gate: false, + }, + ) + .expect_err("negative raw tag is unsigned u32::MAX and exceeds the budget"); + + assert!(matches!( + error, + EngineError::MissionAiRecordSlots { + clan_index: 0, + raw_count: u32::MAX, + .. + } + )); + assert_eq!(loaded_mission(&engine), Some(&previous_mission)); + assert_eq!( + loaded_mission_script_bundles(&engine), + Some(previous_bundles.as_slice()) + ); + assert_eq!( + loaded_mission_ai_startup_states(&engine), + Some(previous_ai_startup_states.as_slice()) + ); + assert_eq!(engine.behavior_rng_state, previous_rng); + assert_eq!( + fparkan_world::canonical_state_hash(&engine.world), + previous_world + ); + } + + #[test] + fn full_load_keeps_independent_trf_tables_for_each_ai_state_clan() { + let vfs = research_startup_test_vfs( + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[ + b"DATA/Research/shared.trf\0ignored.trf", + b"DATA/Research/shared.trf", + ], + Some(&research_test_trf()), + ); + let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine"); + + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::AutoDemo, + }, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: true, + }, + ) + .expect("full load with explicit research gate"); + + let tables = loaded_mission_research_trf_tables(&engine).expect("TRF tables"); + assert_eq!(tables.len(), 2, "each AI state owner gets a table"); + let bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); + assert_eq!(bundles.len(), 1, "only the scripted clan has a bundle"); + assert_eq!(bundles[0].clan_index, 0); + let ai_states = loaded_mission_ai_startup_states(&engine).expect("AI states"); + assert_eq!( + ai_states + .iter() + .map(|state| state.clan_index) + .collect::<Vec<_>>(), + [0, 1] + ); + assert!(ai_states + .iter() + .all(|state| state.record_slots == [u32::MAX; 9])); + assert_eq!(tables[0].clan_index, 0); + assert_eq!(tables[0].path, "DATA/Research/shared.trf"); + assert_eq!(tables[1].clan_index, 1); + assert_eq!(tables[1].path, "DATA/Research/shared.trf"); + assert_eq!( + tables[0].document.row_words(0), + tables[1].document.row_words(0) + ); + assert_eq!(tables[0].document.flag_byte(0), Some(0x5a)); + assert_ne!( + tables[0].document.row_words(0).unwrap().as_ptr(), + tables[1].document.row_words(0).unwrap().as_ptr(), + "each clan owns an independently decoded row buffer" + ); + } + + #[test] + fn full_load_skips_research_trf_when_gate_or_c_string_is_missing() { + for (script_bases, research_paths, gate, mode) in [ + ( + vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])], + vec![&b"DATA/Research/missing.trf"[..]], + false, + MissionMode::AutoDemo, + ), + ( + vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])], + vec![&b""[..]], + true, + MissionMode::SingleMission, + ), + ( + vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])], + vec![&b"\0ignored.trf"[..]], + true, + MissionMode::SingleMission, + ), + ] { + let vfs = research_startup_test_vfs(&script_bases, &research_paths, None); + let mut engine = + create(EngineServices::new(vfs)).expect("research startup test engine"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode, + }, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: gate, + }, + ) + .expect("skipped TRF input must not be read"); + assert!(loaded_mission_research_trf_tables(&engine) + .expect("TRF state") + .is_empty()); + } + } + + #[test] + fn static_preview_skips_research_trf_reads() { + let vfs = research_startup_test_vfs( + &[Some(b"MISSIONS/SCRIPTS/shared")], + &[b"DATA/Research/missing.trf"], + None, + ); + let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine"); + + load_mission_static_preview( + &mut engine, + MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + ) + .expect("preview must not read research TRF paths"); + + assert!(loaded_mission_research_trf_tables(&engine) + .expect("TRF state") + .is_empty()); + assert!(loaded_mission_ai_startup_states(&engine) + .expect("AI state") + .is_empty()); + } + + #[test] + fn no_script_ai_research_trf_read_and_decode_errors_keep_loaded_world_and_rng() { + let initial_vfs = research_startup_test_vfs( + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[b"", b"DATA/Research/shared.trf"], + Some(&research_test_trf()), + ); + let mut engine = create(EngineServices::new(initial_vfs)).expect("research startup engine"); + let request = || MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }; + load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: true, + }, + ) + .expect("initial valid TRF table load"); + let previous_summary = loaded_mission(&engine).expect("loaded mission").clone(); + let previous_table = + &loaded_mission_research_trf_tables(&engine).expect("initial TRF table")[0]; + let previous_owner = previous_table.clan_index; + let previous_path = previous_table.path.clone(); + let previous_row = *previous_table.document.row_words(0).expect("initial row"); + let previous_row_ptr = previous_table.document.row_words(0).unwrap().as_ptr(); + let before = step_headless(&mut engine, InputSnapshot, 100_000) + .expect("step initial mission") + .snapshot + .objects; + let previous_rng = engine.behavior_rng_state; + + for trf in [None, Some(&b"malformed"[..])] { + let vfs = research_startup_test_vfs( + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[b"", b"DATA/Research/shared.trf"], + trf, + ); + engine.services.vfs = Some(vfs); + + let error = load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_100, + research_creation_gate: true, + }, + ) + .expect_err("gate true must report a missing or malformed TRF"); + assert_eq!(loaded_mission(&engine), Some(&previous_summary)); + assert_eq!(engine.behavior_rng_state, previous_rng); + let retained_tables = + loaded_mission_research_trf_tables(&engine).expect("retained TRF tables"); + assert_eq!(retained_tables.len(), 1); + assert_eq!(retained_tables[0].clan_index, previous_owner); + assert_eq!(retained_tables[0].path, previous_path); + assert_eq!( + retained_tables[0].document.row_words(0), + Some(&previous_row) + ); + assert_eq!( + retained_tables[0].document.row_words(0).unwrap().as_ptr(), + previous_row_ptr + ); + let after = step_headless(&mut engine, InputSnapshot, 100_000) + .expect("step retained mission") + .snapshot + .objects; + assert_eq!(before, after); + match (trf, error) { + (None, EngineError::Vfs { path, .. }) => { + assert_eq!(path, "DATA/Research/shared.trf"); + } + ( + Some(_), + EngineError::ResearchTrf { + clan_index, path, .. + }, + ) => { + assert_eq!(clan_index, 1); + assert_eq!(path, "DATA/Research/shared.trf"); + } + (_, error) => panic!("unexpected TRF failure: {error:?}"), + } + } + } + + #[test] + fn research_trf_c_string_paths_reject_non_ascii_bytes() { + let vfs = research_startup_test_vfs( + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[b"", b"DATA/Research/\xe9.trf"], + None, + ); + let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine"); + let error = load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: true, + }, + ) + .expect_err("non-ASCII TRF path must fail explicitly"); + assert!(matches!( + error, + EngineError::ResearchTrfPath { clan_index: 1, path } + if path == b"DATA/Research/\xe9.trf" + )); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_campaign_full_load_creates_trf_tables_for_ai_clan_owners() { + let root = licensed_root("IS"); + let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); + let mut engine = create(EngineServices::new(vfs)).expect("engine"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: true, + }, + ) + .expect("full campaign load with explicit research gate"); + + let tables = loaded_mission_research_trf_tables(&engine).expect("TRF tables"); + assert_eq!( + tables + .iter() + .map(|table| (table.clan_index, table.path.as_str())) + .collect::<Vec<_>>(), + [ + (0, "MISSIONS/SCRIPTS/data.trf"), + (1, "MISSIONS/SCRIPTS/data.trf"), + (2, "MISSIONS/SCRIPTS/data.trf"), + ] + ); + assert!(tables.iter().all(|table| table.document.row_count() > 0)); + assert_eq!( + tables[0].document.row_words(0), + tables[1].document.row_words(0) + ); + assert_eq!( + tables[0].document.row_words(0), + tables[2].document.row_words(0) + ); + assert_ne!( + tables[0].document.row_words(0).unwrap().as_ptr(), + tables[1].document.row_words(0).unwrap().as_ptr() + ); + assert_ne!( + tables[1].document.row_words(0).unwrap().as_ptr(), + tables[2].document.row_words(0).unwrap().as_ptr() + ); + } + #[derive(Clone, Copy)] enum DenyRule { Suffix(&'static str), @@ -14472,24 +18447,37 @@ mod tests { struct SwappingScriptVfs { inner: DirectoryVfs, - replacement: Arc<[u8]>, + replacement_script: Arc<[u8]>, + replacement_formula: Arc<[u8]>, enabled: std::sync::atomic::AtomicBool, } impl SwappingScriptVfs { - fn use_replacement(&self, path: &NormalizedPath) -> bool { - self.enabled.load(std::sync::atomic::Ordering::SeqCst) - && path - .as_str() - .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr") + fn replacement(&self, path: &NormalizedPath) -> Option<&Arc<[u8]>> { + if !self.enabled.load(std::sync::atomic::Ordering::SeqCst) { + return None; + } + if path + .as_str() + .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr") + { + Some(&self.replacement_script) + } else if path + .as_str() + .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.fml") + { + Some(&self.replacement_formula) + } else { + None + } } } impl Vfs for SwappingScriptVfs { fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> { - if self.use_replacement(path) { + if let Some(replacement) = self.replacement(path) { return Ok(VfsMetadata { - len: self.replacement.len() as u64, + len: replacement.len() as u64, fingerprint: [0x18; 32], }); } @@ -14497,8 +18485,8 @@ mod tests { } fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { - if self.use_replacement(path) { - return Ok(self.replacement.clone()); + if let Some(replacement) = self.replacement(path) { + return Ok(replacement.clone()); } self.inner.read(path) } diff --git a/crates/fparkan-runtime/src/mesh_collision.rs b/crates/fparkan-runtime/src/mesh_collision.rs new file mode 100644 index 0000000..6e9ae2d --- /dev/null +++ b/crates/fparkan-runtime/src/mesh_collision.rs @@ -0,0 +1,1255 @@ +//! World-space segment queries over selected building MSH nodes and slots. + +use fparkan_msh::{ + node38_metadata, query_slot_segment, ModelAsset, MshError, NodeId, SegmentQueryMasks, SlotId, +}; +use fparkan_render::RawCameraTransform; + +/// One selected MSH node/slot and its affine transform into world space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BuildingMeshNode { + /// Model-local node id. + pub node: NodeId, + /// Currently selected slot for this node. + pub slot: SlotId, + /// Row-major affine transform from node-local space to world space. + pub world_from_node: [f32; 16], +} + +/// One accepted building mesh hit in world space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BuildingMeshHit { + /// Node that supplied this hit. + pub node: NodeId, + /// Selected slot that supplied this hit. + pub slot: SlotId, + /// Hit position in world space. + pub position: [f32; 3], + /// Squared world distance from the original segment start. + pub distance_squared: f32, + /// Global Batch20 index. + pub batch_index: usize, + /// Global type-7 triangle descriptor index. + pub triangle_descriptor_index: usize, + /// Global Res3 position indices of the hit triangle. + pub vertex_indices: [usize; 3], +} + +const MAX_INTERSECTION_VISITS: usize = 200; + +/// Queries a world-space segment against all supplied building nodes and slots. +/// +/// Node flags use the native camera query's `0/0` masks. The initial batch +/// query excludes `0x0200` while keeping bit `0x0008` visible to the building +/// retry rule; triangle flags use `0/0x0024`. A nearest bit-8 batch hit restarts +/// the query from that world-space point toward the original endpoint, just as +/// the native building camera does. Each helper's Batch20 `local_batch_index` +/// selects the next Node38 root; that retry visits its eligible descendants +/// child-before-self and queries only nodes with supplied geometry bindings. +pub fn query_building_segment_world( + model: &ModelAsset, + nodes: &[BuildingMeshNode], + world_segment: [[f32; 3]; 2], +) -> Result<Option<BuildingMeshHit>, MshError> { + if !world_segment + .iter() + .flatten() + .all(|value| value.is_finite()) + { + return Err(MshError::InvalidGeometry( + "building query segment contains a non-finite endpoint".to_string(), + )); + } + + let prepared_nodes = nodes + .iter() + .map(|node| { + prepare_node(*node).map_err(|reason| { + MshError::InvalidGeometry(format!( + "building node {} has an invalid world transform: {reason}", + node.node.0 + )) + }) + }) + .collect::<Result<Vec<_>, _>>()?; + + let original_start = world_segment[0]; + let original_end = world_segment[1]; + let mut segment_start = original_start; + let mut linked_root = None; + let mut intersection_visits = 0; + loop { + let mut nearest = None; + let query_nodes = if let Some(root) = linked_root { + let order = linked_node_postorder(model, root)?; + let mut ordered = Vec::new(); + for node_id in order { + ordered.extend( + prepared_nodes + .iter() + .filter(|node| node.source.node == node_id), + ); + } + ordered + } else { + prepared_nodes.iter().collect() + }; + for node in query_nodes { + let local_segment = [ + transform_point(node.node_from_world, segment_start) + .ok_or_else(|| invalid_transformed_point(node.source.node, "world start"))?, + transform_point(node.node_from_world, original_end) + .ok_or_else(|| invalid_transformed_point(node.source.node, "world endpoint"))?, + ]; + let hit = query_slot_segment( + model, + node.source.node, + node.source.slot, + local_segment, + SegmentQueryMasks { + node_include: 0, + node_exclude: 0, + batch_include: 0, + // CBuilding clears bit 8 from the camera's 0x208 exclude + // mask so it can restart after inspecting the raw batch. + batch_exclude: 0x0200, + triangle_include: 0, + triangle_exclude: 0x0024, + }, + ) + .map_err(|source| { + MshError::InvalidGeometry(format!( + "building node {} slot {} segment query failed: {source}", + node.source.node.0, node.source.slot.0 + )) + })?; + let Some(hit) = hit else { + continue; + }; + let position = transform_point(node.source.world_from_node, hit.position) + .ok_or_else(|| invalid_transformed_point(node.source.node, "local hit"))?; + let offset = subtract3(position, original_start); + let distance_squared = dot3(offset, offset); + if !distance_squared.is_finite() { + return Err(MshError::InvalidGeometry(format!( + "building node {} produced a non-finite world hit distance", + node.source.node.0 + ))); + } + let batch_flags = model + .batches + .get(hit.batch_index) + .ok_or_else(|| { + MshError::InvalidGeometry(format!( + "building node {} query returned missing batch {}", + node.source.node.0, hit.batch_index + )) + })? + .batch_flags; + let candidate = BuildingMeshHit { + node: node.source.node, + slot: node.source.slot, + position, + distance_squared, + batch_index: hit.batch_index, + triangle_descriptor_index: hit.triangle_descriptor_index, + vertex_indices: hit.vertex_indices, + }; + if nearest.is_none_or(|(best, _): (BuildingMeshHit, u16)| { + candidate.distance_squared <= best.distance_squared + }) { + nearest = Some((candidate, batch_flags)); + } + } + + let Some((nearest, batch_flags)) = nearest else { + return Ok(None); + }; + intersection_visits += 1; + if intersection_visits == MAX_INTERSECTION_VISITS { + return Err(MshError::InvalidGeometry(format!( + "building query exhausted the native {MAX_INTERSECTION_VISITS}-intersection visit limit" + ))); + } + if batch_flags & 0x0008 == 0 { + return Ok(Some(nearest)); + } + let batch = model.batches.get(nearest.batch_index).ok_or_else(|| { + MshError::InvalidGeometry(format!( + "building query returned missing batch {}", + nearest.batch_index + )) + })?; + if batch.local_batch_index == u16::MAX { + return Err(MshError::InvalidGeometry(format!( + "building helper batch {} has the sentinel linked-node key", + nearest.batch_index + ))); + } + let linked_node = NodeId(u32::from(batch.local_batch_index)); + if usize::try_from(linked_node.0).unwrap_or(usize::MAX) >= model.node_count { + return Err(MshError::InvalidGeometry(format!( + "building helper batch {} has linked node {} outside Node38 count {}", + nearest.batch_index, linked_node.0, model.node_count + ))); + } + segment_start = nearest.position; + linked_root = Some(linked_node); + } +} + +/// Builds the native child-before-self query order rooted at a portal link. +/// +/// Node metadata is traversed even when the caller has no selected geometry +/// binding for that node, because eligible descendants can still carry hits. +fn linked_node_postorder(model: &ModelAsset, root: NodeId) -> Result<Vec<NodeId>, MshError> { + let root_index = usize::try_from(root.0).map_err(|_| { + MshError::InvalidGeometry(format!("linked query node {} is not addressable", root.0)) + })?; + if root_index >= model.node_count { + return Err(MshError::InvalidGeometry(format!( + "linked query node {} is outside Node38 count {}", + root.0, model.node_count + ))); + } + + let metadata = (0..model.node_count) + .map(|index| { + let node = NodeId(u32::try_from(index).map_err(|_| { + MshError::InvalidGeometry(format!("Node38 index {index} is not addressable")) + })?); + node38_metadata(model, node).ok_or_else(|| { + MshError::InvalidGeometry(format!( + "linked query requires Node38 metadata for node {index}" + )) + }) + }) + .collect::<Result<Vec<_>, _>>()?; + for (index, entry) in metadata.iter().enumerate() { + if entry.parent_or_link_raw != u16::MAX + && usize::from(entry.parent_or_link_raw) >= model.node_count + { + return Err(MshError::InvalidGeometry(format!( + "Node38 parent {} for node {index} is outside node count {}", + entry.parent_or_link_raw, model.node_count + ))); + } + } + + // Each stack frame marks entry or postorder exit. This keeps traversal + // bounded by the model table rather than by the native hierarchy depth. + let mut stack = vec![(root_index, false)]; + let mut active = vec![false; model.node_count]; + let mut visited = vec![false; model.node_count]; + let mut ordered = Vec::new(); + while let Some((index, exiting)) = stack.pop() { + if exiting { + active[index] = false; + visited[index] = true; + ordered.push(NodeId(u32::try_from(index).map_err(|_| { + MshError::InvalidGeometry(format!("Node38 index {index} is not addressable")) + })?)); + continue; + } + if active[index] { + return Err(MshError::InvalidGeometry(format!( + "linked Node38 query contains a cycle at node {index}" + ))); + } + if visited[index] { + continue; + } + + active[index] = true; + stack.push((index, true)); + if metadata[index].header_flags & 0x0010 == 0 { + let parent_word = u16::try_from(index).map_err(|_| { + MshError::InvalidGeometry(format!( + "Node38 index {index} cannot be represented by its parent field" + )) + })?; + for child in (0..model.node_count).rev() { + if metadata[child].parent_or_link_raw != u16::MAX + && metadata[child].parent_or_link_raw == parent_word + && metadata[child].header_flags & 0x0004 == 0 + { + stack.push((child, false)); + } + } + } + } + Ok(ordered) +} + +#[derive(Clone, Copy)] +struct PreparedNode { + source: BuildingMeshNode, + node_from_world: [f32; 16], +} + +fn prepare_node(node: BuildingMeshNode) -> Result<PreparedNode, &'static str> { + if !node.world_from_node.iter().all(|value| value.is_finite()) { + return Err("matrix contains a non-finite value"); + } + if node.world_from_node[12].abs() > f32::EPSILON + || node.world_from_node[13].abs() > f32::EPSILON + || node.world_from_node[14].abs() > f32::EPSILON + || (node.world_from_node[15] - 1.0).abs() > f32::EPSILON + { + return Err("matrix is not affine"); + } + let node_from_world = RawCameraTransform { + words: node.world_from_node.map(f32::to_bits), + } + .try_inverse_affine_row_major() + .ok_or("matrix is singular or its inverse is not finite")?; + if !node_from_world.iter().all(|value| value.is_finite()) { + return Err("matrix inverse contains a non-finite value"); + } + Ok(PreparedNode { + source: node, + node_from_world, + }) +} + +fn invalid_transformed_point(node: NodeId, label: &str) -> MshError { + MshError::InvalidGeometry(format!( + "building node {} transformed {label} to a non-finite point", + node.0 + )) +} + +fn transform_point(matrix: [f32; 16], point: [f32; 3]) -> Option<[f32; 3]> { + let transformed = [ + matrix[0] * point[0] + matrix[1] * point[1] + matrix[2] * point[2] + matrix[3], + matrix[4] * point[0] + matrix[5] * point[1] + matrix[6] * point[2] + matrix[7], + matrix[8] * point[0] + matrix[9] * point[1] + matrix[10] * point[2] + matrix[11], + ]; + transformed + .iter() + .all(|value| value.is_finite()) + .then_some(transformed) +} + +fn subtract3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 { + left[0] * right[0] + left[1] * right[1] + left[2] * right[2] +} + +#[cfg(test)] +mod tests { + use super::{ + query_building_segment_world, BuildingMeshHit, BuildingMeshNode, MAX_INTERSECTION_VISITS, + }; + use fparkan_msh::{ + query_slot_segment, Batch, ModelAsset, MshError, NodeId, SegmentQueryMasks, Slot, SlotId, + TriangleDescriptor, + }; + + const IDENTITY: [f32; 16] = [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ]; + const CAMERA_MASKS: SegmentQueryMasks = SegmentQueryMasks { + node_include: 0, + node_exclude: 0, + batch_include: 0, + batch_exclude: 0x0200, + triangle_include: 0, + triangle_exclude: 0x0024, + }; + + fn node(node: u32, world_from_node: [f32; 16]) -> BuildingMeshNode { + node_with_slot(node, 0, world_from_node) + } + + fn node_with_slot(node: u32, slot: u32, world_from_node: [f32; 16]) -> BuildingMeshNode { + BuildingMeshNode { + node: NodeId(node), + slot: SlotId(slot), + world_from_node, + } + } + + fn model_with_planes(planes: &[(f32, u16)]) -> ModelAsset { + model_with_slots(&[planes]) + } + + fn model_with_slots(planes_per_slot: &[&[(f32, u16)]]) -> ModelAsset { + model_with_slots_and_nodes(planes_per_slot, 2) + } + + fn model_with_slots_and_nodes( + planes_per_slot: &[&[(f32, u16)]], + node_count: usize, + ) -> ModelAsset { + let mut positions = Vec::new(); + let mut indices = Vec::new(); + let mut batches = Vec::new(); + let mut triangle_descriptors = Vec::new(); + let mut slots = Vec::new(); + for planes in planes_per_slot { + let descriptor_start = triangle_descriptors.len(); + let batch_start = batches.len(); + let min_y = planes.iter().map(|(y, _)| *y).fold(0.0, f32::min); + let max_y = planes.iter().map(|(y, _)| *y).fold(0.0, f32::max); + let center_y = (min_y + max_y) * 0.5; + let half_height = (max_y - min_y) * 0.5; + for (y, batch_flags) in *planes { + let base_vertex = positions.len(); + let index_start = indices.len(); + positions.extend([[-20.0, *y, -20.0], [0.0, *y, 20.0], [20.0, *y, -20.0]]); + indices.extend([0, 1, 2]); + batches.push(Batch { + batch_flags: *batch_flags, + material_index_hi: 0, + material_index: 0, + lightmap_index: 0, + local_batch_index: u16::MAX, + index_count: 3, + index_start: u32::try_from(index_start).expect("small synthetic index offset"), + vertex_count: 3, + base_vertex: u32::try_from(base_vertex).expect("small synthetic vertex offset"), + }); + triangle_descriptors.push(TriangleDescriptor { + tri_flags: 0, + opaque: [0; 4], + normal: [0, 32767, 0], + }); + } + slots.push(Slot { + tri_start: u16::try_from(descriptor_start) + .expect("small synthetic descriptor start"), + tri_count: u16::try_from(triangle_descriptors.len() - descriptor_start) + .expect("small synthetic descriptor count"), + batch_start: u16::try_from(batch_start).expect("small synthetic batch start"), + batch_count: u16::try_from(batches.len() - batch_start) + .expect("small synthetic batch count"), + aabb_min: [-20.0, min_y, -20.0], + aabb_max: [20.0, max_y, 20.0], + sphere_center: [0.0, center_y, 0.0], + sphere_radius: (20.0_f32.powi(2) * 2.0 + half_height.powi(2)).sqrt(), + opaque: [0; 5], + }); + } + + ModelAsset { + node_stride: 38, + node_count, + nodes_raw: vec![0; node_count * 38], + type16_present: false, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, + slots, + positions, + normals: None, + uv0: None, + uv1: None, + indices, + batches, + triangle_descriptors: Some(triangle_descriptors), + node_names: None, + animation: None, + } + } + + fn set_node_metadata(model: &mut ModelAsset, node: usize, flags: u16, parent: u16) { + let offset = node * model.node_stride; + model.nodes_raw[offset..offset + 2].copy_from_slice(&flags.to_le_bytes()); + model.nodes_raw[offset + 2..offset + 4].copy_from_slice(&parent.to_le_bytes()); + } + + fn set_batch_link(model: &mut ModelAsset, batch: usize, node: u16) { + model.batches[batch].local_batch_index = node; + } + + fn set_root_node_metadata(model: &mut ModelAsset) { + for node in 0..model.node_count { + set_node_metadata(model, node, 0, u16::MAX); + } + } + + fn translated(x: f32, y: f32, z: f32) -> [f32; 16] { + let mut matrix = IDENTITY; + matrix[3] = x; + matrix[7] = y; + matrix[11] = z; + matrix + } + + fn hit(result: Option<BuildingMeshHit>) -> BuildingMeshHit { + result.expect("segment should hit the synthetic plane") + } + + fn assert_point(actual: [f32; 3], expected: [f32; 3]) { + for axis in 0..3 { + assert!( + (actual[axis] - expected[axis]).abs() < 1.0e-4, + "{actual:?} != {expected:?}" + ); + } + } + + #[test] + fn translation_returns_a_world_space_hit() { + let result = query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, translated(4.0, 5.0, -3.0))], + [[4.0, 8.0, -3.0], [4.0, 2.0, -3.0]], + ) + .expect("valid world query"); + + let hit = hit(result); + assert_point(hit.position, [4.0, 5.0, -3.0]); + assert_eq!(hit.distance_squared, 9.0); + } + + #[test] + fn rotation_transforms_the_world_segment_into_node_space() { + let rotated = [ + 0.0, -1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ]; + let result = query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, rotated)], + [[-3.0, 0.0, 0.0], [3.0, 0.0, 0.0]], + ) + .expect("valid world query"); + + assert_point(hit(result).position, [0.0, 0.0, 0.0]); + } + + #[test] + fn nonuniform_scale_preserves_world_hit_coordinates() { + let scaled = [ + 2.0, 0.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 1.0, + ]; + let result = query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, scaled)], + [[0.0, 2.0, 0.25], [0.0, -2.0, 0.25]], + ) + .expect("valid world query"); + + let hit = hit(result); + assert_point(hit.position, [0.0, 0.0, 0.25]); + assert_eq!(hit.distance_squared, 4.0); + } + + #[test] + fn mirrored_transform_uses_the_mirrored_world_side() { + let mirrored = [ + 1.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ]; + let result = query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, mirrored)], + [[0.0, -3.0, 0.0], [0.0, 3.0, 0.0]], + ) + .expect("valid mirrored world query"); + + assert_point(hit(result).position, [0.0, 0.0, 0.0]); + } + + #[test] + fn nearest_hit_is_ranked_in_world_space_from_original_start() { + let mut farther = translated(0.0, 0.0, 0.0); + let mut nearer = translated(0.0, 2.0, 0.0); + // Make local distances deliberately incomparable across nodes. + farther[5] = 2.0; + nearer[5] = 0.5; + let result = query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, farther), node(1, nearer)], + [[0.0, 5.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid world query"); + + let hit = hit(result); + assert_eq!(hit.node, NodeId(1)); + assert_point(hit.position, [0.0, 2.0, 0.0]); + assert_eq!(hit.distance_squared, 9.0); + } + + #[test] + fn retries_from_bit8_helper_to_the_farther_wall() { + let helper_plane = [(2.0, 0x0008)]; + let wall_plane = [(0.0, 0)]; + let mut model = model_with_slots(&[&helper_plane, &wall_plane]); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 1); + let result = query_building_segment_world( + &model, + &[ + node_with_slot(0, 0, IDENTITY), + node_with_slot(1, 1, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid world query"); + + let hit = hit(result); + assert_eq!(hit.node, NodeId(1)); + assert_eq!(hit.slot, SlotId(1)); + assert_eq!(hit.batch_index, 1); + assert_point(hit.position, [0.0, 0.0, 0.0]); + assert_eq!(hit.distance_squared, 9.0); + } + + #[test] + fn coplanar_helper_hit_is_requeried_instead_of_filtered_out() { + let mut model = model_with_slots(&[&[(0.0, 0)], &[(0.0, 0x0008)]]); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 1, 1); + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(0, 1, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid world query"); + + assert!(result.is_none()); + } + + #[test] + fn world_roundtrip_helper_cannot_be_reselected_after_portal_link() { + const START: [f32; 3] = [301.39682, 806.24396, 2.868942]; + const END: [f32; 3] = [304.3922, 806.4102, 2.868942]; + let world_from_node = [ + f32::from_bits(0xbd62_f745), + f32::from_bits(0xbf7f_9b4f), + 0.0, + f32::from_bits(0x4379_43ca), + f32::from_bits(0x3f7f_9b4f), + f32::from_bits(0xbd62_f745), + 0.0, + f32::from_bits(0x4448_b667), + 0.0, + 0.0, + 1.0, + f32::from_bits(0x4110_2494), + 0.0, + 0.0, + 0.0, + 1.0, + ]; + let mut model = model_with_slots_and_nodes(&[&[(-54.239994, 0x0008)]], 4); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 0, 0, u16::MAX); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x00d1, 2); + let source = node(1, world_from_node); + + // Reproduce the old all-node retry: the world-space first hit maps + // back to a local start just above this same helper plane. + let prepared = super::prepare_node(source).expect("invert actual AutoDemo matrix"); + let local_start = + super::transform_point(prepared.node_from_world, START).expect("finite local start"); + let local_end = + super::transform_point(prepared.node_from_world, END).expect("finite local endpoint"); + let first_hit = query_slot_segment( + &model, + NodeId(1), + SlotId(0), + [local_start, local_end], + CAMERA_MASKS, + ) + .expect("valid first helper query") + .expect("first helper crossing"); + let world_hit = + super::transform_point(world_from_node, first_hit.position).expect("finite world hit"); + let repeated_local_start = super::transform_point(prepared.node_from_world, world_hit) + .expect("finite round-tripped start"); + assert!(query_slot_segment( + &model, + NodeId(1), + SlotId(0), + [repeated_local_start, local_end], + CAMERA_MASKS, + ) + .expect("valid repeated helper query") + .is_some()); + + let result = query_building_segment_world(&model, &[source], [START, END]) + .expect("valid portal-linked world query"); + assert!(result.is_none()); + } + + #[test] + fn linked_subtree_finds_drawable_child_through_nondrawable_parent() { + let mut model = model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)]], 4); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x00d1, 2); + + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(3, 1, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid linked-child query"); + + let hit = hit(result); + assert_eq!(hit.node, NodeId(3)); + assert_eq!(hit.slot, SlotId(1)); + assert_eq!(hit.batch_index, 1); + assert_point(hit.position, [0.0, 0.0, 0.0]); + } + + #[test] + fn linked_query_is_child_before_self_and_self_bit4_does_not_skip_geometry() { + let mut model = + model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0)]], 4); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x00d1, 2); + + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(2, 1, IDENTITY), + node_with_slot(3, 2, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid child-first linked query"); + + let hit = hit(result); + // The equal-distance later candidate replaces the child result. + assert_eq!(hit.node, NodeId(2)); + assert_eq!(hit.slot, SlotId(1)); + } + + #[test] + fn linked_query_traverses_nondrawable_nodes_and_skips_child_bit4_branches() { + let mut model = model_with_slots_and_nodes( + &[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0)], &[(0.0, 0)]], + 7, + ); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x0000, 2); + set_node_metadata(&mut model, 4, 0x0010, 3); + set_node_metadata(&mut model, 5, 0x0004, 3); + set_node_metadata(&mut model, 6, 0x0004, 2); + + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(4, 1, IDENTITY), + node_with_slot(5, 2, IDENTITY), + node_with_slot(6, 3, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid linked branch query"); + + let hit = hit(result); + assert_eq!(hit.node, NodeId(4)); + assert_eq!(hit.slot, SlotId(1)); + } + + #[test] + fn linked_query_keeps_native_batch_and_triangle_exclude_masks() { + let mut model = + model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0x0200)]], 5); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x00d1, 2); + set_node_metadata(&mut model, 4, 0x00d1, 2); + model + .triangle_descriptors + .as_mut() + .expect("synthetic type-7 table")[1] + .tri_flags = 0x0024; + + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(3, 1, IDENTITY), + node_with_slot(4, 2, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid query with native exclusion masks"); + + assert!(result.is_none()); + } + + #[test] + fn linked_order_stops_descent_at_header_bit10_but_keeps_self() { + let mut model = model_with_slots_and_nodes(&[&[]], 5); + set_root_node_metadata(&mut model); + set_node_metadata(&mut model, 1, 0, 0); + set_node_metadata(&mut model, 2, 0x0014, 1); + set_node_metadata(&mut model, 3, 0, 2); + set_node_metadata(&mut model, 4, 0, 3); + + let order = super::linked_node_postorder(&model, NodeId(2)).expect("valid linked tree"); + + assert_eq!(order, [NodeId(2)]); + } + + #[test] + fn invalid_portal_link_and_linked_cycle_are_explicit_errors() { + let mut invalid_link = model_with_planes(&[(2.0, 0x0008)]); + set_root_node_metadata(&mut invalid_link); + set_batch_link(&mut invalid_link, 0, 2); + assert!(matches!( + query_building_segment_world( + &invalid_link, + &[node(0, IDENTITY)], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ), + Err(MshError::InvalidGeometry(message)) if message.contains("linked") + )); + + let mut cycle = model_with_slots_and_nodes(&[&[(2.0, 0x0008)]], 3); + set_batch_link(&mut cycle, 0, 1); + set_node_metadata(&mut cycle, 0, 0, u16::MAX); + set_node_metadata(&mut cycle, 1, 0, 2); + set_node_metadata(&mut cycle, 2, 0, 1); + assert!(matches!( + query_building_segment_world( + &cycle, + &[node(0, IDENTITY)], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ), + Err(MshError::InvalidGeometry(message)) if message.contains("cycle") + )); + } + + #[test] + fn invalid_non_affine_singular_and_nonfinite_transforms_are_explicit_errors() { + let mut non_affine = IDENTITY; + non_affine[12] = 0.5; + assert!(matches!( + query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, non_affine)], + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + ), + Err(MshError::InvalidGeometry(message)) if message.contains("not affine") + )); + + let mut singular = IDENTITY; + singular[5] = 0.0; + assert!(matches!( + query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, singular)], + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + ), + Err(MshError::InvalidGeometry(message)) if message.contains("singular") + )); + + let mut nonfinite = IDENTITY; + nonfinite[0] = f32::NAN; + assert!(matches!( + query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, nonfinite)], + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + ), + Err(MshError::InvalidGeometry(message)) if message.contains("non-finite") + )); + } + + #[test] + fn wall_after_198_helpers_is_accepted_as_the_199th_hit() { + let helper_count = MAX_INTERSECTION_VISITS - 2; + let mut planes = (0..helper_count) + .map(|index| ((helper_count - index) as f32, 0x0008)) + .collect::<Vec<_>>(); + planes.push((0.0, 0)); + let mut model = model_with_planes(&planes); + set_root_node_metadata(&mut model); + for batch in 0..helper_count { + set_batch_link(&mut model, batch, 0); + } + let result = query_building_segment_world( + &model, + &[node(0, IDENTITY)], + [[0.0, (helper_count + 1) as f32, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("199th intersection is below the native limit"); + + let hit = hit(result); + assert_eq!(hit.batch_index, helper_count); + assert_point(hit.position, [0.0, 0.0, 0.0]); + } + + #[test] + fn wall_after_199_helpers_errors_as_the_200th_hit() { + let helper_count = MAX_INTERSECTION_VISITS - 1; + let mut planes = (0..helper_count) + .map(|index| ((helper_count - index) as f32, 0x0008)) + .collect::<Vec<_>>(); + planes.push((0.0, 0)); + let mut model = model_with_planes(&planes); + set_root_node_metadata(&mut model); + for batch in 0..helper_count { + set_batch_link(&mut model, batch, 0); + } + let result = query_building_segment_world( + &model, + &[node(0, IDENTITY)], + [[0.0, (helper_count + 1) as f32, 0.0], [0.0, -1.0, 0.0]], + ); + + assert!(matches!( + result, + Err(MshError::InvalidGeometry(message)) if message.contains("200") + )); + } + + fn compose_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { + let mut result = [0.0; 16]; + for row in 0..4 { + for column in 0..4 { + for inner in 0..4 { + result[row * 4 + column] += left[row * 4 + inner] * right[inner * 4 + column]; + } + } + } + result + } + + fn pose_matrix(pose: fparkan_animation::Pose) -> [f32; 16] { + let x = rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation); + let y = rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation); + let z = rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation); + [ + x[0], + y[0], + z[0], + pose.translation[0], + x[1], + y[1], + z[1], + pose.translation[1], + x[2], + y[2], + z[2], + pose.translation[2], + 0.0, + 0.0, + 0.0, + 1.0, + ] + } + + fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] { + let [x, y, z, w] = rotation; + let tx = 2.0 * (y * value[2] - z * value[1]); + let ty = 2.0 * (z * value[0] - x * value[2]); + let tz = 2.0 * (x * value[1] - y * value[0]); + [ + value[0] + w * tx + (y * tz - z * ty), + value[1] + w * ty + (z * tx - x * tz), + value[2] + w * tz + (x * ty - y * tx), + ] + } + + fn scale_matrix(scale: [f32; 3]) -> [f32; 16] { + [ + scale[0], 0.0, 0.0, 0.0, 0.0, scale[1], 0.0, 0.0, 0.0, 0.0, scale[2], 0.0, 0.0, 0.0, + 0.0, 1.0, + ] + } + + fn node_times_from_bindings( + node_count: usize, + bindings: &[fparkan_assets::PreparedControlNodeBinding], + ) -> Vec<Option<fparkan_animation::AnimationTime>> { + use fparkan_animation::AnimationTime; + + let mut times = vec![Some(AnimationTime(0.0)); node_count]; + for binding in bindings { + if binding.flags & 0x04 != 0 { + continue; + } + assert!(binding.node_index < node_count); + let mut blend = if binding.flags & 0x01 != 0 { + let mut value = binding.initial_blend; + if value > 1.0 { + value -= 1.0; + } + if value < 0.0 { + value += 1.0; + } + value + } else { + binding.initial_blend.clamp(0.0, 1.0) + }; + if binding.flags & 0x02 != 0 { + blend = 1.0 - blend; + } + times[binding.node_index] = Some(AnimationTime( + (1.0 - blend) * binding.frame_a + blend * binding.frame_b, + )); + } + times + } + + fn nodes_for_hierarchy( + model: &ModelAsset, + poses: &[fparkan_animation::Pose], + root_from_body: [f32; 16], + owner_scale: [f32; 3], + ) -> Vec<BuildingMeshNode> { + use fparkan_msh::{selected_slot, Group, Lod}; + + let scale = scale_matrix(owner_scale); + (0..model.node_count) + .filter_map(|index| { + let node = NodeId(u32::try_from(index).expect("model node id fits u32")); + let slot = selected_slot(model, node, Lod(0), Group(0))?; + let slot_data = &model.slots[usize::try_from(slot.0).expect("slot id fits usize")]; + let batch_start = usize::from(slot_data.batch_start); + let batch_end = batch_start + usize::from(slot_data.batch_count); + if !model.batches[batch_start..batch_end] + .iter() + .any(|batch| batch.index_count != 0) + { + return None; + } + let node_pose = pose_matrix(*poses.get(index).expect("Node38 pose available")); + let body_from_node = compose_row_major(scale, node_pose); + let world_from_node = compose_row_major(root_from_body, body_from_node); + Some(BuildingMeshNode { + node, + slot, + world_from_node, + }) + }) + .collect() + } + + #[test] + #[ignore = "requires licensed corpus"] + fn factory_plant_world_query_preserves_native_fixture_hit_under_affine_transforms() { + use fparkan_assets::decode_nres_payload; + use fparkan_msh::{ + decode_msh, node38_fallback_hierarchy, selected_slot, validate_msh, Group, Lod, NodeId, + SlotId, + }; + use std::{path::PathBuf, sync::Arc}; + + const BODY_WALL: [[f32; 3]; 2] = [[-10.5, 22.16, 2.5], [-10.5, 19.16, 2.5]]; + const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]]; + const BODY_REVERSED_WALL: [[f32; 3]; 2] = [BODY_WALL[1], BODY_WALL[0]]; + const BODY_HIT: [f32; 3] = [-10.5, 20.16, 2.5]; + const ROTATED_SCALED_TRANSLATED: [f32; 16] = [ + 0.0, -3.0, 0.0, 7.0, 2.0, 0.0, 0.0, -4.0, 0.0, 0.0, 0.5, 2.0, 0.0, 0.0, 0.0, 1.0, + ]; + + let root = PathBuf::from( + std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed corpus tests"), + ); + let archive_bytes = std::fs::read(root.join("fortif.rlb")).expect("fortif.rlb bytes"); + let archive = decode_nres_payload(Arc::from(archive_bytes.into_boxed_slice())) + .expect("fortif.rlb archive"); + let entry = archive + .entries() + .iter() + .find(|entry| entry.name_bytes().eq_ignore_ascii_case(b"fr_l_plant.msh")) + .expect("plant factory MSH"); + let nested = decode_nres_payload(Arc::from( + archive + .payload(entry.id()) + .expect("MSH payload") + .to_vec() + .into_boxed_slice(), + )) + .expect("nested plant MSH"); + let model = validate_msh(&decode_msh(&nested).expect("plant MSH document")) + .expect("plant MSH model"); + let node_id = NodeId(1); + let slot = selected_slot(&model, node_id, Lod(0), Group(0)).expect("Node 1 slot"); + assert_eq!(slot, SlotId(0)); + + let pose = node38_fallback_hierarchy(&model) + .expect("Node38 fallback hierarchy") + .poses[1]; + assert_eq!(pose.rotation, [0.0, 0.0, 0.0, 1.0]); + let body_from_node = translated( + pose.translation[0], + pose.translation[1], + pose.translation[2], + ); + + for body_to_world in [IDENTITY, ROTATED_SCALED_TRANSLATED] { + let world_from_node = compose_row_major(body_to_world, body_from_node); + let node = BuildingMeshNode { + node: node_id, + slot, + world_from_node, + }; + let to_world = |point| { + super::transform_point(body_to_world, point).expect("finite fixture transform") + }; + let world_wall = [to_world(BODY_WALL[0]), to_world(BODY_WALL[1])]; + let expected_hit = to_world(BODY_HIT); + let hit = query_building_segment_world(&model, &[node], world_wall) + .expect("valid factory wall query") + .expect("solid factory wall hit"); + + assert_eq!(hit.node, node_id); + assert_eq!(hit.slot, slot); + assert_eq!(hit.batch_index, 5); + assert_eq!(hit.triangle_descriptor_index, 62); + for axis in 0..3 { + assert!( + (hit.position[axis] - expected_hit[axis]).abs() <= 1.0e-3, + "world hit {:?} != {:?}", + hit.position, + expected_hit + ); + } + let world_delta = [ + expected_hit[0] - world_wall[0][0], + expected_hit[1] - world_wall[0][1], + expected_hit[2] - world_wall[0][2], + ]; + let expected_distance_squared = super::dot3(world_delta, world_delta); + assert!( + (hit.distance_squared - expected_distance_squared).abs() <= 1.0e-2, + "world distance squared {} != {}", + hit.distance_squared, + expected_distance_squared + ); + + for body_segment in [BODY_DOOR, BODY_REVERSED_WALL] { + let world_segment = [to_world(body_segment[0]), to_world(body_segment[1])]; + assert!(query_building_segment_world(&model, &[node], world_segment) + .expect("valid factory miss query") + .is_none()); + } + } + } + + #[test] + #[ignore = "requires licensed AutoDemo corpus"] + fn autodemo_portal_uses_node3_authored_closed_and_open_poses() { + use crate::{ + create, load_mission_static_preview_roots, loaded_mission_assets, + loaded_mission_object_drafts, EngineServices, MissionMode, MissionRequest, + }; + use fparkan_animation::AnimationTime; + use fparkan_msh::{node38_fallback_hierarchy, node38_sampled_hierarchy_at_times}; + use fparkan_render::LegacyIron3dEulerTransform; + use fparkan_vfs::DirectoryVfs; + use std::{num::NonZeroUsize, path::PathBuf, sync::Arc}; + + const OBJECT_INDEX: usize = 4; + const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]]; + + let root = PathBuf::from( + std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed corpus tests"), + ); + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(&root)))) + .expect("create SingleMission fixture engine"); + load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + NonZeroUsize::MAX, + ) + .expect("load SingleMission preview roots"); + let assets = loaded_mission_assets(&engine).expect("loaded mission assets"); + let drafts = loaded_mission_object_drafts(&engine).expect("loaded object drafts"); + let draft = &drafts[OBJECT_INDEX]; + assert!(draft + .resource_name_raw + .eq_ignore_ascii_case(b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat")); + let visual_id = *assets + .visuals_for_object(OBJECT_INDEX) + .first() + .expect("PLANT root visual"); + let visual = assets.visual_by_id(visual_id).expect("PLANT visual"); + let model_id = visual.model_id.expect("PLANT MSH model"); + let model = &assets + .model_by_id(model_id) + .expect("prepared PLANT model") + .validated; + assert_eq!(model.node_count, 29); + + let controls = assets + .object_component_controls + .get(OBJECT_INDEX) + .and_then(|components| components.first()) + .and_then(Option::as_ref) + .expect("PLANT component CTLD bindings"); + let node3_binding = controls + .bindings + .iter() + .find(|binding| binding.node_index == 3) + .expect("Node3 CTLD binding"); + assert_eq!(node3_binding.frame_a, 0.0); + assert_eq!(node3_binding.frame_b, 1.0); + assert_eq!(node3_binding.initial_blend, 0.0); + + let mut node_times = node_times_from_bindings(model.node_count, &controls.bindings); + let closed_hierarchy = node38_sampled_hierarchy_at_times(model, &node_times) + .or_else(|| node38_fallback_hierarchy(model)) + .expect("initial Node38 hierarchy"); + assert_eq!(node_times[3], Some(AnimationTime(0.0))); + + let root_transform = LegacyIron3dEulerTransform { + translation: draft.position, + orientation_radians: draft.orientation_raw, + }; + let root_from_body = root_transform + .try_row_major() + .expect("finite PLANT root matrix"); + let world_door = BODY_DOOR.map(|point| { + root_transform + .try_transform_scaled_point(point, draft.scale) + .expect("finite BODY_DOOR world point") + }); + let closed_nodes = + nodes_for_hierarchy(model, &closed_hierarchy.poses, root_from_body, draft.scale); + let closed = query_building_segment_world(model, &closed_nodes, world_door) + .expect("closed-door building query") + .expect("authored time-0 doorway blocker"); + assert_eq!(closed.node, NodeId(3)); + assert_eq!(closed.slot, SlotId(25)); + assert_eq!(closed.batch_index, 100); + assert_eq!(closed.triangle_descriptor_index, 855); + assert_eq!(model.batches[closed.batch_index].batch_flags, 0x2000); + + node_times[3] = Some(AnimationTime(node3_binding.frame_b)); + let open_hierarchy = node38_sampled_hierarchy_at_times(model, &node_times) + .expect("authored Node3 time-1 hierarchy"); + let open_nodes = + nodes_for_hierarchy(model, &open_hierarchy.poses, root_from_body, draft.scale); + assert!(query_building_segment_world(model, &open_nodes, world_door) + .expect("time-1 building query") + .is_none()); + } +} diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index 088b51f..20821fe 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -3,7 +3,7 @@ //! //! This module preserves the layout proven by the GOG `ai.dll` reader. It //! deliberately does not implement a general bytecode VM. It contains a -//! narrow, handler-specific resolver for the confirmed `Init` path. +//! narrow resolver for confirmed `Init` handlers and bounded FML expressions. use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits}; use std::sync::Arc; @@ -40,7 +40,7 @@ pub enum VarSetType { Dword, } -/// A lossless-in-meaning numeric default from `varset.var`. +/// A numeric cell value that preserves the declared `varset.var` type. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum VarSetDefault { /// IEEE-754 bits parsed from a `float` literal. @@ -151,33 +151,155 @@ pub struct Handler19DwordWrite { pub value: u32, } -/// Result of resolving the first event whose case-sensitive C-string name is -/// `Init` (bytes through the first NUL) with the supported -/// `Handler(19)` action subset. +/// A typed variable write emitted by the recovered first-`Init` subset. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ScriptInitWrite { + /// Compiled varset declaration index. + pub index: u32, + /// Value in the original cell's numeric representation. + pub value: VarSetDefault, +} + +/// Result of resolving the first case-sensitive `Init` event. #[derive(Clone, Debug, Eq, PartialEq)] -pub enum Handler19InitResolution { - /// No event has the case-sensitive C-string name `Init`; the native lookup - /// makes no call. +pub enum ScriptInitResolution { + /// No event has the case-sensitive C-string name `Init`. Missing, - /// The first `Init\0` event was walked in source order. + /// The first `Init` event completed within the recovered subset. Executed { - /// Zero-based event table index selected by the native name lookup. + /// Zero-based selected event index. event_index: usize, - /// Opaque event word retained from the selected record. + /// Opaque event word retained from the package. event_word: u32, - /// Ordered writes emitted by every supported instruction in the event. - writes: Vec<Handler19DwordWrite>, + /// Ordered, typed writes emitted by the event. + writes: Vec<ScriptInitWrite>, }, - /// The first unsupported instruction stopped this restricted resolver. - /// Any earlier local writes are discarded because the event did not finish. + /// The event reached a known but unsupported form; earlier writes are discarded. Unsupported { - /// Zero-based event table index selected by the native name lookup. + /// Zero-based selected event index. event_index: usize, - /// Zero-based instruction index within that event. + /// Zero-based unsupported instruction index. instruction_index: usize, - /// Unsupported action shape. - error: Handler19ResolveError, + /// Unsupported instruction or expression form. + error: ScriptInitResolveError, + }, +} + +/// A typed failure or unsupported form in the recovered `Init` subset. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ScriptInitResolveError { + /// The selector is outside the recovered `Handler(19)` and formula assignment paths. + UnsupportedSelector { + /// Observed selector. + found: ScriptDispatchSelector, + }, + /// A `Handler(19)` reference or declaration did not match its recovered contract. + Handler19(Handler19ResolveError), + /// A formula record has an unsupported control word. + UnsupportedFormulaControlWord { + /// Raw disk word at index 3. + found: u32, + }, + /// A formula record requests the separate whole-varset-record copy path. + UnsupportedFormulaCopySource { + /// Raw disk word at index 2. + found: u32, + }, + /// The selected script base did not provide an FML table. + MissingFormulaTable, + /// The FML ordinal is outside the preserved row table. + FormulaIndexOutOfBounds { + /// Zero-based FML row index. + index: u32, + /// Number of preserved FML rows. + rows: usize, }, + /// The referenced FML row is outside the supported atom-expression subset. + UnsupportedFormulaExpression { + /// Zero-based FML row index. + index: u32, + /// Raw expression bytes from the row. + expression: Vec<u8>, + }, + /// A DWORD target cannot represent the native truncating qword conversion. + DwordConversionOutOfRange { + /// Compiled target index. + index: u32, + /// Evaluated binary32 payload. + value_bits: u32, + }, + /// The formula record target is outside the selected varset. + VarSetIndexOutOfBounds { + /// Compiled target index. + index: u32, + /// Available declaration count. + declarations: usize, + }, +} + +impl std::fmt::Display for ScriptInitResolveError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsupportedSelector { found } => { + write!(formatter, "unsupported script selector {found:?} in Init") + } + Self::Handler19(source) => write!(formatter, "Handler(19): {source}"), + Self::UnsupportedFormulaControlWord { found } => write!( + formatter, + "unsupported formula control word 0x{found:08x}; expected 0xffffffff" + ), + Self::UnsupportedFormulaCopySource { found } => write!( + formatter, + "unsupported formula source 0x{found:08x}; whole-varset copy is not modeled" + ), + Self::MissingFormulaTable => write!(formatter, "formula assignment has no FML table"), + Self::FormulaIndexOutOfBounds { index, rows } => write!( + formatter, + "formula index {index} is outside the FML table with {rows} rows" + ), + Self::UnsupportedFormulaExpression { index, expression } => write!( + formatter, + "formula {index} expression {:?} is outside the supported atom and binary +/− subset", + String::from_utf8_lossy(expression) + ), + Self::DwordConversionOutOfRange { index, value_bits } => write!( + formatter, + "formula value 0x{value_bits:08x} cannot be converted to DWORD target {index}" + ), + Self::VarSetIndexOutOfBounds { + index, + declarations, + } => write!( + formatter, + "formula target {index} is outside {declarations} varset declarations" + ), + } + } +} + +impl std::error::Error for ScriptInitResolveError {} + +enum InitInstructionResolution { + Executed(Vec<ScriptInitWrite>), + Unsupported(ScriptInitResolveError), +} + +/// Rows loaded from one script base's `.fml` file in native table order. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct FormulaTable { + /// Accepted `FUNCTION` rows in their original order, including unsupported expressions. + pub rows: Vec<FormulaRow>, +} + +/// One source row accepted by the native FML line loader. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FormulaRow { + /// Name token retained from the first comma-delimited field. + pub name_raw: Vec<u8>, + /// Expression bytes from the second field; interpretation is lazy. + pub expression_raw: Vec<u8>, + /// Final field bytes, retained without interpreting their meaning. + pub trailing_raw: Vec<u8>, } /// Inputs already expressed at the original `Handler(19)` setter ABI boundary. @@ -205,12 +327,12 @@ pub enum Handler19ResolveError { }, /// Only action instructions with control word `0xffff_ffff` are supported. UnsupportedControlWord { - /// Opaque control word from instruction header word one. + /// Opaque control word from disk-order header index 3. found: u32, }, /// Only instructions with no result-save reference are supported. UnsupportedResultSave { - /// Opaque variable reference from instruction header word two. + /// Opaque variable reference from disk-order header index 1. found: u32, }, /// One of the three required references was absent. @@ -583,95 +705,220 @@ impl VarSet { ]) } - /// Resolves only the first event whose case-sensitive C-string name is - /// `Init` (bytes through the first NUL), and its supported - /// `Handler(19)` action instructions in source order. + fn resolve_handler19_target( + &self, + instruction: &ScriptInstruction, + position: usize, + ) -> Result<u32, Handler19ResolveError> { + let index = *instruction + .references + .get(position) + .ok_or(Handler19ResolveError::MissingReference { position })?; + match self.declarations.get(index as usize) { + Some(VarSetDeclaration { + type_name: VarSetType::Dword, + .. + }) => Ok(index), + Some(declaration) => Err(Handler19ResolveError::UnexpectedType { + position, + found: declaration.type_name, + }), + None => Err(Handler19ResolveError::VarSetIndexOutOfBounds { + index, + declarations: self.declarations.len(), + }), + } + } + + /// Resolves the first case-sensitive `Init` event's recovered write subset. + /// + /// Handler(19) writes use the caller's original setter-boundary words. A + /// sentinel selector is accepted only for the proven formula assignment + /// with control and copy-source words both equal to `0xffffffff`; the + /// referenced row is evaluated only when that instruction is reached. + /// Formula atoms read defaults and earlier successful writes in this event. + /// + /// # Errors /// - /// A missing event is a normal no-write result, matching the native event - /// lookup. Unsupported selector/control/result-save forms stop at their - /// first instruction and return no partial writes. Malformed handler - /// references and declaration types remain errors. - pub fn resolve_handler19_init( + /// Returns a typed error for malformed Handler(19) references, invalid + /// formula or varset indices, and targets outside the selected varset. + pub fn resolve_init( &self, package: &ScriptPackage, input: Handler19InitInput, - ) -> Result<Handler19InitResolution, Handler19ResolveError> { + formulas: Option<&FormulaTable>, + ) -> Result<ScriptInitResolution, ScriptInitResolveError> { let Some((event_index, event)) = package.events.iter().enumerate().find(|(_, event)| { event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]) }) else { - return Ok(Handler19InitResolution::Missing); + return Ok(ScriptInitResolution::Missing); }; + let mut values = self + .declarations + .iter() + .map(|declaration| declaration.default_value) + .collect::<Vec<_>>(); let mut writes = Vec::new(); for (instruction_index, instruction) in event.instructions.iter().enumerate() { - let instruction_writes = - match self.resolve_handler19_init_instruction(instruction, input) { - Ok(writes) => writes, - Err(error @ Handler19ResolveError::UnsupportedSelector { .. }) - | Err(error @ Handler19ResolveError::UnsupportedControlWord { .. }) - | Err(error @ Handler19ResolveError::UnsupportedResultSave { .. }) => { - return Ok(Handler19InitResolution::Unsupported { - event_index, - instruction_index, - error, - }); + match self.resolve_init_instruction(instruction, input, formulas, &values)? { + InitInstructionResolution::Executed(instruction_writes) => { + for write in &instruction_writes { + let Some(value) = values.get_mut(write.index as usize) else { + return Err(ScriptInitResolveError::VarSetIndexOutOfBounds { + index: write.index, + declarations: self.declarations.len(), + }); + }; + *value = write.value; } - Err(error) => return Err(error), - }; - writes.extend(instruction_writes); + writes.extend(instruction_writes); + } + InitInstructionResolution::Unsupported(error) => { + return Ok(ScriptInitResolution::Unsupported { + event_index, + instruction_index, + error, + }); + } + } } - Ok(Handler19InitResolution::Executed { + Ok(ScriptInitResolution::Executed { event_index, event_word: event.event_word, writes, }) } - fn resolve_handler19_init_instruction( + fn resolve_init_instruction( &self, instruction: &ScriptInstruction, input: Handler19InitInput, - ) -> Result<[Handler19DwordWrite; 3], Handler19ResolveError> { - let selector = instruction.dispatch_selector(); - if selector != ScriptDispatchSelector::Handler(19) { - return Err(Handler19ResolveError::UnsupportedSelector { found: selector }); - } - if instruction.header_words[1] != u32::MAX { - return Err(Handler19ResolveError::UnsupportedControlWord { - found: instruction.header_words[1], - }); - } - if instruction.header_words[2] != u32::MAX { - return Err(Handler19ResolveError::UnsupportedResultSave { - found: instruction.header_words[2], - }); - } - self.resolve_handler19(instruction, input) - } - - fn resolve_handler19_target( - &self, - instruction: &ScriptInstruction, - position: usize, - ) -> Result<u32, Handler19ResolveError> { - let index = *instruction - .references - .get(position) - .ok_or(Handler19ResolveError::MissingReference { position })?; - match self.declarations.get(index as usize) { - Some(VarSetDeclaration { - type_name: VarSetType::Dword, - .. - }) => Ok(index), - Some(declaration) => Err(Handler19ResolveError::UnexpectedType { - position, - found: declaration.type_name, - }), - None => Err(Handler19ResolveError::VarSetIndexOutOfBounds { - index, - declarations: self.declarations.len(), - }), + formulas: Option<&FormulaTable>, + values: &[VarSetDefault], + ) -> Result<InitInstructionResolution, ScriptInitResolveError> { + match instruction.dispatch_selector() { + ScriptDispatchSelector::Handler(19) => { + if instruction.header_words[3] != u32::MAX { + return Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::Handler19( + Handler19ResolveError::UnsupportedControlWord { + found: instruction.header_words[3], + }, + ), + )); + } + if instruction.header_words[1] != u32::MAX { + return Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::Handler19( + Handler19ResolveError::UnsupportedResultSave { + found: instruction.header_words[1], + }, + ), + )); + } + match self.resolve_handler19(instruction, input) { + Ok(writes) => Ok(InitInstructionResolution::Executed( + writes + .into_iter() + .map(|write| ScriptInitWrite { + index: write.index, + value: VarSetDefault::Dword(write.value), + }) + .collect(), + )), + Err(error @ Handler19ResolveError::UnsupportedControlWord { .. }) + | Err(error @ Handler19ResolveError::UnsupportedResultSave { .. }) => { + Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::Handler19(error), + )) + } + Err(error) => Err(ScriptInitResolveError::Handler19(error)), + } + } + ScriptDispatchSelector::Sentinel => { + if instruction.header_words[3] != u32::MAX { + return Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::UnsupportedFormulaControlWord { + found: instruction.header_words[3], + }, + )); + } + if instruction.header_words[2] != u32::MAX { + return Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::UnsupportedFormulaCopySource { + found: instruction.header_words[2], + }, + )); + } + let Some(formulas) = formulas else { + return Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::MissingFormulaTable, + )); + }; + let formula_index = instruction.header_words[6]; + let Some(formula) = formulas.rows.get(formula_index as usize) else { + return Err(ScriptInitResolveError::FormulaIndexOutOfBounds { + index: formula_index, + rows: formulas.rows.len(), + }); + }; + let Some(value) = parse_init_formula_expression( + &formula.expression_raw, + &self.declarations, + values, + ) else { + return Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::UnsupportedFormulaExpression { + index: formula_index, + expression: formula.expression_raw.clone(), + }, + )); + }; + let target_index = instruction.header_words[1]; + let Some(target) = self.declarations.get(target_index as usize) else { + return Err(ScriptInitResolveError::VarSetIndexOutOfBounds { + index: target_index, + declarations: self.declarations.len(), + }); + }; + let Some(current_target) = values.get(target_index as usize) else { + return Err(ScriptInitResolveError::VarSetIndexOutOfBounds { + index: target_index, + declarations: values.len(), + }); + }; + if !varset_value_matches_type(target.type_name, *current_target) { + return Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::UnsupportedFormulaExpression { + index: formula_index, + expression: formula.expression_raw.clone(), + }, + )); + } + let value = match target.type_name { + VarSetType::Float => VarSetDefault::FloatBits(value.to_bits()), + VarSetType::Dword => { + let Some(value) = formula_dword_value(value) else { + return Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::DwordConversionOutOfRange { + index: target_index, + value_bits: value.to_bits(), + }, + )); + }; + VarSetDefault::Dword(value) + } + }; + Ok(InitInstructionResolution::Executed(vec![ScriptInitWrite { + index: target_index, + value, + }])) + } + found => Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::UnsupportedSelector { found }, + )), } } @@ -870,6 +1117,393 @@ impl std::fmt::Display for VarSetError { impl std::error::Error for VarSetError {} +/// A failure evaluating one row as a single native numeric literal. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FormulaLiteralError { + /// The referenced ordinal was outside the preserved table. + IndexOutOfBounds { + /// Zero-based row index. + index: u32, + /// Number of accepted rows. + rows: usize, + }, + /// The row's expression was not one numeric token. + UnsupportedExpression { + /// Zero-based row index. + index: u32, + /// Raw expression bytes. + expression: Vec<u8>, + }, +} + +impl std::fmt::Display for FormulaLiteralError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::IndexOutOfBounds { index, rows } => { + write!(formatter, "formula index {index} is outside {rows} rows") + } + Self::UnsupportedExpression { index, expression } => write!( + formatter, + "formula {index} expression {:?} is not one numeric literal", + String::from_utf8_lossy(expression) + ), + } + } +} + +impl std::error::Error for FormulaLiteralError {} + +impl FormulaTable { + /// Parses native `FUNCTION` rows without compiling their expressions. + /// + /// Input is read as text: CRLF becomes LF, Ctrl-Z ends input, and each + /// 255-byte read chunk is parsed independently. Accepted rows stay in + /// source order so formula references retain their original ordinals. + pub fn parse(bytes: &[u8]) -> Self { + let mut text = Vec::with_capacity(bytes.len()); + let mut offset = 0; + while let Some(&byte) = bytes.get(offset) { + if byte == 0x1a { + break; + } + if byte == b'\r' && bytes.get(offset + 1) == Some(&b'\n') { + text.push(b'\n'); + offset += 2; + } else { + text.push(byte); + offset += 1; + } + } + + let mut table = Self::default(); + let mut offset = 0; + while offset < text.len() { + let limit = (offset + 255).min(text.len()); + let end = text[offset..limit] + .iter() + .position(|byte| *byte == b'\n') + .map_or(limit, |newline| offset + newline + 1); + parse_formula_chunk(&mut table, &text[offset..end]); + offset = end; + } + table + } + + /// Evaluates a preserved row only when it is referenced by a formula assignment. + /// + /// The lexer behavior is bounded to the original number-token rules. The + /// converter consumes the longest decimal prefix, converts it to `f64`, + /// then rounds to the node's binary32 payload. + /// + /// # Errors + /// + /// Returns a typed error for an out-of-range ordinal or an expression + /// containing more than one supported numeric token. + pub fn evaluate_numeric_literal(&self, index: u32) -> Result<f32, FormulaLiteralError> { + let Some(row) = self.rows.get(index as usize) else { + return Err(FormulaLiteralError::IndexOutOfBounds { + index, + rows: self.rows.len(), + }); + }; + parse_native_numeric_literal(&row.expression_raw).ok_or_else(|| { + FormulaLiteralError::UnsupportedExpression { + index, + expression: row.expression_raw.clone(), + } + }) + } +} + +fn parse_formula_chunk(table: &mut FormulaTable, chunk: &[u8]) { + let visible_len = chunk + .iter() + .position(|byte| *byte == 0) + .unwrap_or(chunk.len()); + let visible = &chunk[..visible_len]; + let first_token = native_first_token(visible); + if first_token.iter().take(2).any(|byte| *byte == b'/') { + return; + } + if !visible + .windows(b"FUNCTION".len()) + .any(|part| part == b"FUNCTION") + { + return; + } + let Some(open) = visible.iter().position(|byte| *byte == b'(') else { + return; + }; + let first_comma = visible.iter().position(|byte| *byte == b','); + let second_comma = first_comma.and_then(|first| { + visible[first + 1..] + .iter() + .position(|byte| *byte == b',') + .map(|next| first + 1 + next) + }); + let mut buffer = visible.to_vec(); + buffer.push(0); + for byte in &mut buffer[..visible_len] { + if *byte == b',' { + *byte = 0; + } + } + + let name_start = open + 1; + let expression_start = first_comma.map(|comma| comma + 1); + let trailing_start = second_comma.map(|comma| comma + 1); + let last_field_start = trailing_start.or(expression_start).unwrap_or(name_start); + let last_field_end = c_string_end(&buffer, last_field_start); + if let Some(close) = buffer[last_field_start..last_field_end] + .iter() + .rposition(|byte| *byte == b')') + { + buffer[last_field_start + close] = 0; + } + + let name_field = c_string(&buffer, name_start); + if name_field.is_empty() { + return; + } + let expression_field = expression_start.map_or(&[][..], |start| c_string(&buffer, start)); + let trailing_field = trailing_start.map_or(&[][..], |start| c_string(&buffer, start)); + let name_token = native_first_token(name_field); + let name_raw = if name_token.is_empty() { + b"no_name".to_vec() + } else { + name_token.to_vec() + }; + table.rows.push(FormulaRow { + name_raw, + expression_raw: expression_field.to_vec(), + trailing_raw: trailing_field.to_vec(), + }); +} + +fn c_string_end(buffer: &[u8], start: usize) -> usize { + buffer[start..] + .iter() + .position(|byte| *byte == 0) + .map_or(buffer.len(), |offset| start + offset) +} + +fn c_string(buffer: &[u8], start: usize) -> &[u8] { + &buffer[start..c_string_end(buffer, start)] +} + +fn native_first_token(field: &[u8]) -> &[u8] { + let start = field + .iter() + .position(|byte| *byte == 0 || *byte > 32) + .unwrap_or(field.len()); + let end = field[start..] + .iter() + .position(|byte| *byte == 0 || *byte <= 32) + .map_or(field.len(), |offset| start + offset); + &field[start..end] +} + +fn parse_native_numeric_literal(expression: &[u8]) -> Option<f32> { + let start = expression + .iter() + .position(|byte| *byte == 0 || *byte > 32) + .unwrap_or(expression.len()); + let expression = &expression[start..]; + let token_len = native_numeric_token_len(expression)?; + if expression[token_len..] + .iter() + .any(|byte| *byte != 0 && *byte > 32) + { + return None; + } + let converted_len = native_decimal_prefix_len(&expression[..token_len])?; + let prefix = std::str::from_utf8(&expression[..converted_len]).ok()?; + prefix.parse::<f64>().ok().map(|value| value as f32) +} + +fn native_numeric_token_len(expression: &[u8]) -> Option<usize> { + if !expression.first().is_some_and(u8::is_ascii_digit) { + return None; + } + let mut index = 0; + let mut dot_allowed = true; + let mut exponent_sign_allowed = false; + while let Some(&byte) = expression.get(index) { + if byte.is_ascii_digit() { + exponent_sign_allowed = false; + index += 1; + } else if byte == b'.' && dot_allowed { + dot_allowed = false; + exponent_sign_allowed = false; + index += 1; + } else if matches!(byte, b'e' | b'E') { + dot_allowed = true; + exponent_sign_allowed = true; + index += 1; + } else if matches!(byte, b'+' | b'-') && exponent_sign_allowed { + exponent_sign_allowed = false; + index += 1; + } else { + break; + } + } + Some(index) +} + +fn native_decimal_prefix_len(token: &[u8]) -> Option<usize> { + let mut index = 0; + while token.get(index).is_some_and(u8::is_ascii_digit) { + index += 1; + } + if token.get(index) == Some(&b'.') { + index += 1; + while token.get(index).is_some_and(u8::is_ascii_digit) { + index += 1; + } + } + if index == 0 { + return None; + } + + if token + .get(index) + .is_some_and(|byte| matches!(byte, b'e' | b'E')) + { + let exponent_start = index; + index += 1; + if token + .get(index) + .is_some_and(|byte| matches!(byte, b'+' | b'-')) + { + index += 1; + } + let digits_start = index; + while token.get(index).is_some_and(u8::is_ascii_digit) { + index += 1; + } + if digits_start == index { + index = exponent_start; + } + } + Some(index) +} + +fn parse_init_formula_expression( + expression: &[u8], + declarations: &[VarSetDeclaration], + values: &[VarSetDefault], +) -> Option<f32> { + let (left, left_end) = parse_init_formula_atom(expression, 0, declarations, values)?; + let operator_index = skip_native_formula_space(expression, left_end); + let Some(&operator @ (b'+' | b'-')) = expression.get(operator_index) else { + return expression[left_end..] + .iter() + .all(|byte| *byte == 0 || *byte <= 32) + .then_some(left); + }; + let (right, right_end) = + parse_init_formula_atom(expression, operator_index + 1, declarations, values)?; + if !expression[right_end..] + .iter() + .all(|byte| *byte == 0 || *byte <= 32) + { + return None; + } + Some(match operator { + b'+' => right + left, + b'-' => left - right, + _ => unreachable!(), + }) +} + +fn parse_init_formula_atom( + expression: &[u8], + start: usize, + declarations: &[VarSetDeclaration], + values: &[VarSetDefault], +) -> Option<(f32, usize)> { + let start = skip_native_formula_space(expression, start); + let remaining = expression.get(start..)?; + let first = *remaining.first()?; + let token_len = if first.is_ascii_digit() { + native_numeric_token_len(remaining)? + } else { + native_variable_token_len(remaining)? + }; + let token = &remaining[..token_len]; + if is_native_operator_atom(token) { + return None; + } + + let value = if let Some(index) = declarations + .iter() + .position(|declaration| declaration.name.as_bytes() == token) + { + let declaration = &declarations[index]; + let value = *values.get(index)?; + varset_value_as_f32(declaration.type_name, value)? + } else { + parse_native_numeric_literal(token)? + }; + Some((value, start + token_len)) +} + +fn native_variable_token_len(token: &[u8]) -> Option<usize> { + let first = *token.first()?; + if first == b'^' || !is_native_variable_byte(first) { + return None; + } + Some( + token + .iter() + .take_while(|byte| is_native_variable_byte(**byte)) + .count(), + ) +} + +fn is_native_variable_byte(byte: u8) -> bool { + byte.is_ascii() && ((byte > b'@' && byte < b'{') || byte.is_ascii_digit() || byte == b'_') +} + +fn is_native_operator_atom(token: &[u8]) -> bool { + matches!( + token, + b"+" | b"-" | b"*" | b"/" | b"^" | b"&" | b"|" | b"!" | b"A" | b"B" | b"N" | b"S" + ) +} + +fn skip_native_formula_space(expression: &[u8], start: usize) -> usize { + expression[start..] + .iter() + .position(|byte| *byte == 0 || *byte > 32) + .map_or(expression.len(), |offset| start + offset) +} + +fn varset_value_as_f32(type_name: VarSetType, value: VarSetDefault) -> Option<f32> { + match (type_name, value) { + (VarSetType::Float, VarSetDefault::FloatBits(bits)) => Some(f32::from_bits(bits)), + (VarSetType::Dword, VarSetDefault::Dword(value)) => Some(value as f32), + _ => None, + } +} + +fn varset_value_matches_type(type_name: VarSetType, value: VarSetDefault) -> bool { + matches!( + (type_name, value), + (VarSetType::Float, VarSetDefault::FloatBits(_)) + | (VarSetType::Dword, VarSetDefault::Dword(_)) + ) +} + +fn formula_dword_value(value: f32) -> Option<u32> { + const SIGNED_QWORD_LIMIT: f64 = 9_223_372_036_854_775_808.0; + let value = f64::from(value); + if !value.is_finite() || value < -SIGNED_QWORD_LIMIT || value >= SIGNED_QWORD_LIMIT { + return None; + } + Some(value.trunc() as i64 as u32) +} + /// A compiled `.scr` package. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ScriptPackage { @@ -1317,15 +1951,58 @@ fn read_instruction( #[cfg(test)] mod tests { use super::{ - decode, decode_with_limits, parse_varset, Handler15ResolveError, Handler15TargetPayload, - Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError, + decode, decode_with_limits, parse_varset, FormulaTable, Handler15ResolveError, + Handler15TargetPayload, Handler19DwordWrite, Handler19InitInput, Handler19ResolveError, Handler2RecordInput, Handler2RecordScheduler, Handler30ResolveError, Handler8Reset, - Handler8ResolveError, ScriptDispatchSelector, ScriptEvent, ScriptInstruction, - ScriptPackage, VarSetDefault, VarSetError, VarSetType, VmHostCallbackCommand, - GOG_HANDLER_COUNT, INSTRUCTION_WORDS, + Handler8ResolveError, ScriptDispatchSelector, ScriptEvent, ScriptInitResolution, + ScriptInitResolveError, ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSetDefault, + VarSetError, VarSetType, VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, }; use fparkan_binary::{DecodeError, Limits}; + fn init_package(instructions: Vec<ScriptInstruction>) -> ScriptPackage { + ScriptPackage { + opcode_handler_count: 73, + events: vec![ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0, + instructions, + }], + trailing_bytes: Vec::new(), + raw: std::sync::Arc::from(&b""[..]), + } + } + + fn init_formula_assignment(target: u32, formula: u32) -> ScriptInstruction { + ScriptInstruction { + header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, formula], + references: Vec::new(), + } + } + + fn init_handler19() -> ScriptInstruction { + ScriptInstruction { + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], + references: vec![0, 1, 2], + } + } + + fn init_input() -> Handler19InitInput { + Handler19InitInput { + first_x87_word: 10, + second_x87_word: 20, + third_word: 30, + } + } + + fn one_formula(expression: &[u8]) -> FormulaTable { + let mut source = b"FUNCTION(test, ".to_vec(); + source.extend_from_slice(expression); + source.extend_from_slice(b", )\n"); + FormulaTable::parse(&source) + } + fn handler_two_input( scalar_1: f32, scalar_4: f32, @@ -1470,6 +2147,73 @@ STRING( 8, ignored, ignored, ignored)\r\n"; } #[test] + fn formula_table_preserves_native_rows_and_field_defaults() { + let table = FormulaTable::parse( + b"// a comment\r\nFUNCTION(no_fields)\r\nFUNCTION(name,0)\r\nFUNCTION(name,0,)\r\nFUNCTION( , 2, )\r\nFUNCTION(, 3,)\r\nFUNCTION missing_parenthesis,4,\r\nFUNCTION()\r\n/x FUNCTION(ignored,5,)\r\nx/FUNCTION(ignored,6,)\r\nx,FUNCTION(name,0,)\r\nx,FUNCTION(name)\r\n", + ); + + assert_eq!(table.rows.len(), 6); + assert_eq!(table.rows[0].name_raw, b"no_fields"); + assert!(table.rows[0].expression_raw.is_empty()); + assert_eq!(table.rows[1].name_raw, b"name"); + assert_eq!(table.rows[1].expression_raw, b"0"); + assert!(table.rows[1].trailing_raw.is_empty()); + assert_eq!(table.rows[2].expression_raw, b"0"); + assert!(table.rows[2].trailing_raw.is_empty()); + assert_eq!(table.rows[3].name_raw, b"no_name"); + assert_eq!(table.rows[3].expression_raw, b" 2"); + assert_eq!(table.rows[4].name_raw, b"name"); + assert_eq!(table.rows[4].expression_raw, b"FUNCTION(name"); + assert_eq!(table.rows[4].trailing_raw, b"0"); + assert_eq!(table.rows[5].name_raw, b"name"); + assert_eq!(table.rows[5].expression_raw, b"FUNCTION(name"); + assert!(table.rows[5].trailing_raw.is_empty()); + } + + #[test] + fn formula_table_chunks_without_reassembling_and_stops_at_text_eof() { + let mut source = vec![b' '; 255]; + source.extend_from_slice(b"FUNCTION(before, 1, )\r\n"); + source.extend_from_slice(b"FUNCTION(after, 2, )\r\n"); + source.push(0x1a); + source.extend_from_slice(b"FUNCTION(ignored, 3, )\r\n"); + + let table = FormulaTable::parse(&source); + assert_eq!(table.rows.len(), 2); + assert_eq!(table.rows[0].name_raw, b"before"); + assert_eq!(table.rows[1].name_raw, b"after"); + } + + #[test] + fn formula_table_uses_only_the_nul_terminated_view_of_each_chunk() { + let table = FormulaTable::parse( + b"FUNCTION(visible, 1, )\0 FUNCTION(hidden, 2, )\nFUNCTION(next, 3, )\n", + ); + assert_eq!(table.rows.len(), 2); + assert_eq!(table.rows[0].name_raw, b"visible"); + assert_eq!(table.rows[1].name_raw, b"next"); + } + + #[test] + fn formula_literal_evaluation_uses_the_native_numeric_prefix() { + let table = FormulaTable::parse( + b"FUNCTION(scientific,1e-2,)\nFUNCTION(integer_dot,1.,)\nFUNCTION(exp_prefix,1e2.3,)\nFUNCTION(incomplete,1e+,)\nFUNCTION(unary,-1,)\nFUNCTION(operator,1+2,)\n", + ); + + for (index, expected) in [(0, 0.01_f32), (1, 1.0), (2, 100.0), (3, 1.0)] { + assert_eq!( + table + .evaluate_numeric_literal(index) + .expect("native numeric token"), + expected + ); + } + assert!(table.evaluate_numeric_literal(4).is_err()); + assert!(table.evaluate_numeric_literal(5).is_err()); + assert!(table.evaluate_numeric_literal(6).is_err()); + } + + #[test] fn handler_thirty_resolves_only_proven_dword_operands() { let varset = parse_varset( b"VAR( float, f0, 0.5)\nVAR( DWORD, first, 0x12)\nVAR( DWORD, second, 9)\n", @@ -1684,11 +2428,11 @@ STRING( 8, ignored, ignored, ignored)\r\n"; } #[test] - fn handler_nineteen_init_uses_first_exact_event_and_keeps_instruction_order() { + fn script_init_uses_first_exact_event_and_keeps_instruction_order() { let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n") .expect("DWORD targets"); let action = |references: Vec<u32>| ScriptInstruction { - header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], references, }; let package = ScriptPackage { @@ -1715,34 +2459,53 @@ STRING( 8, ignored, ignored, ignored)\r\n"; }; let result = varset - .resolve_handler19_init( + .resolve_init( &package, Handler19InitInput { first_x87_word: 7, second_x87_word: 8, third_word: 9, }, + None, ) .expect("valid first Init event"); assert_eq!( result, - Handler19InitResolution::Executed { + ScriptInitResolution::Executed { event_index: 0, event_word: 0x1234, writes: vec![ - Handler19DwordWrite { index: 0, value: 7 }, - Handler19DwordWrite { index: 1, value: 8 }, - Handler19DwordWrite { index: 2, value: 9 }, - Handler19DwordWrite { index: 1, value: 7 }, - Handler19DwordWrite { index: 0, value: 8 }, - Handler19DwordWrite { index: 2, value: 9 }, + ScriptInitWrite { + index: 0, + value: VarSetDefault::Dword(7), + }, + ScriptInitWrite { + index: 1, + value: VarSetDefault::Dword(8), + }, + ScriptInitWrite { + index: 2, + value: VarSetDefault::Dword(9), + }, + ScriptInitWrite { + index: 1, + value: VarSetDefault::Dword(7), + }, + ScriptInitWrite { + index: 0, + value: VarSetDefault::Dword(8), + }, + ScriptInitWrite { + index: 2, + value: VarSetDefault::Dword(9), + }, ], } ); } #[test] - fn handler_nineteen_init_missing_exact_name_has_no_writes() { + fn script_init_missing_exact_name_has_no_writes() { let varset = parse_varset(b"").expect("empty varset"); let package = ScriptPackage { opcode_handler_count: 73, @@ -1765,20 +2528,21 @@ STRING( 8, ignored, ignored, ignored)\r\n"; }; assert_eq!( - varset.resolve_handler19_init( + varset.resolve_init( &package, Handler19InitInput { first_x87_word: 7, second_x87_word: 8, third_word: 9, }, + None, ), - Ok(Handler19InitResolution::Missing) + Ok(ScriptInitResolution::Missing) ); } #[test] - fn handler_nineteen_init_accepts_empty_first_event_as_executed_with_no_writes() { + fn script_init_accepts_empty_first_event_as_executed_with_no_writes() { let varset = parse_varset(b"").expect("empty varset"); let package = ScriptPackage { opcode_handler_count: 73, @@ -1804,15 +2568,16 @@ STRING( 8, ignored, ignored, ignored)\r\n"; }; assert_eq!( - varset.resolve_handler19_init( + varset.resolve_init( &package, Handler19InitInput { first_x87_word: 7, second_x87_word: 8, third_word: 9, }, + None, ), - Ok(Handler19InitResolution::Executed { + Ok(ScriptInitResolution::Executed { event_index: 0, event_word: 0x1234, writes: Vec::new(), @@ -1821,7 +2586,7 @@ STRING( 8, ignored, ignored, ignored)\r\n"; } #[test] - fn handler_nineteen_init_reports_unsupported_selector_and_discards_prior_writes() { + fn script_init_reports_unsupported_selector_and_discards_prior_writes() { let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n") .expect("DWORD targets"); let package = ScriptPackage { @@ -1832,7 +2597,7 @@ STRING( 8, ignored, ignored, ignored)\r\n"; event_word: 0, instructions: vec![ ScriptInstruction { - header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], references: vec![0, 1, 2], }, ScriptInstruction { @@ -1840,7 +2605,7 @@ STRING( 8, ignored, ignored, ignored)\r\n"; references: Vec::new(), }, ScriptInstruction { - header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], references: vec![0, 1, 2], }, ], @@ -1850,18 +2615,19 @@ STRING( 8, ignored, ignored, ignored)\r\n"; }; assert_eq!( - varset.resolve_handler19_init( + varset.resolve_init( &package, Handler19InitInput { first_x87_word: 7, second_x87_word: 8, third_word: 9, }, + None, ), - Ok(Handler19InitResolution::Unsupported { + Ok(ScriptInitResolution::Unsupported { event_index: 0, instruction_index: 1, - error: Handler19ResolveError::UnsupportedSelector { + error: ScriptInitResolveError::UnsupportedSelector { found: super::ScriptDispatchSelector::Handler(18), }, }) @@ -1869,7 +2635,7 @@ STRING( 8, ignored, ignored, ignored)\r\n"; } #[test] - fn handler_nineteen_init_stops_at_unsupported_selector_before_any_write() { + fn script_init_stops_at_unsupported_selector_before_any_write() { let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n") .expect("DWORD targets"); let package = ScriptPackage { @@ -1884,7 +2650,7 @@ STRING( 8, ignored, ignored, ignored)\r\n"; references: Vec::new(), }, ScriptInstruction { - header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], references: vec![0, 1, 2], }, ], @@ -1894,18 +2660,19 @@ STRING( 8, ignored, ignored, ignored)\r\n"; }; assert_eq!( - varset.resolve_handler19_init( + varset.resolve_init( &package, Handler19InitInput { first_x87_word: 7, second_x87_word: 8, third_word: 9, }, + None, ), - Ok(Handler19InitResolution::Unsupported { + Ok(ScriptInitResolution::Unsupported { event_index: 0, instruction_index: 0, - error: Handler19ResolveError::UnsupportedSelector { + error: ScriptInitResolveError::UnsupportedSelector { found: super::ScriptDispatchSelector::Handler(18), }, }) @@ -1913,7 +2680,7 @@ STRING( 8, ignored, ignored, ignored)\r\n"; } #[test] - fn handler_nineteen_init_types_unsupported_control_and_result_save() { + fn script_init_uses_handler_nineteen_control_and_result_fields() { let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n") .expect("DWORD targets"); let input = Handler19InitInput { @@ -1921,41 +2688,484 @@ STRING( 8, ignored, ignored, ignored)\r\n"; second_x87_word: 8, third_word: 9, }; - let instruction = |control_word, result_word| ScriptInstruction { - header_words: [19, control_word, result_word, 0, 0, 3, 0], - references: vec![0, 1, 2], - }; - let resolve = |instruction| { + let resolve = |header_words| { let package = ScriptPackage { opcode_handler_count: 73, events: vec![ScriptEvent { name_len: 4, name_raw: b"Init\0".to_vec(), event_word: 0, - instructions: vec![instruction], + instructions: vec![ScriptInstruction { + header_words, + references: vec![0, 1, 2], + }], }], trailing_bytes: Vec::new(), raw: std::sync::Arc::from(&b""[..]), }; - varset.resolve_handler19_init(&package, input) + varset.resolve_init(&package, input, None) + }; + + let actual = ( + resolve([19, u32::MAX, u32::MAX, 2, 6, 3, u32::MAX]), + resolve([19, 0x1234, u32::MAX, u32::MAX, 6, 3, u32::MAX]), + resolve([19, u32::MAX, 0xdecafbad, u32::MAX, 6, 3, u32::MAX]), + ); + assert_eq!( + actual, + ( + Ok(ScriptInitResolution::Unsupported { + event_index: 0, + instruction_index: 0, + error: ScriptInitResolveError::Handler19( + Handler19ResolveError::UnsupportedControlWord { found: 2 }, + ), + }), + Ok(ScriptInitResolution::Unsupported { + event_index: 0, + instruction_index: 0, + error: ScriptInitResolveError::Handler19( + Handler19ResolveError::UnsupportedResultSave { found: 0x1234 }, + ), + }), + Ok(ScriptInitResolution::Executed { + event_index: 0, + event_word: 0, + writes: vec![ + ScriptInitWrite { + index: 0, + value: VarSetDefault::Dword(7), + }, + ScriptInitWrite { + index: 1, + value: VarSetDefault::Dword(8), + }, + ScriptInitWrite { + index: 2, + value: VarSetDefault::Dword(9), + }, + ], + }), + ) + ); + } + + #[test] + fn script_init_assigns_numeric_literals_using_the_target_type() { + let varset = parse_varset(b"VAR(DWORD, integer, 0)\nVAR(float, scalar, 0)\n") + .expect("numeric declarations"); + let package = ScriptPackage { + opcode_handler_count: 73, + events: vec![ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0, + instructions: vec![ + ScriptInstruction { + header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 0xdead_beef, 0, 0], + references: Vec::new(), + }, + ScriptInstruction { + header_words: [u32::MAX, 1, u32::MAX, u32::MAX, 0xdead_beef, 0, 1], + references: Vec::new(), + }, + ], + }], + trailing_bytes: Vec::new(), + raw: std::sync::Arc::from(&b""[..]), + }; + let formulas = FormulaTable::parse( + b"FUNCTION(integer, 12.75, )\nFUNCTION(scalar, 0.8, )\nFUNCTION(unused, target + 2, )\n", + ); + + assert_eq!( + varset.resolve_init( + &package, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ), + Ok(ScriptInitResolution::Executed { + event_index: 0, + event_word: 0, + writes: vec![ + ScriptInitWrite { + index: 0, + value: VarSetDefault::Dword(12), + }, + ScriptInitWrite { + index: 1, + value: VarSetDefault::FloatBits(0x3f4c_cccd), + }, + ], + }) + ); + } + + #[test] + fn script_init_formula_variables_read_prior_writes_and_self() { + let varset = parse_varset( + b"VAR(DWORD, source, 0)\nVAR(DWORD, y, 0)\nVAR(DWORD, id, 0)\nVAR(float, target, 0)\nVAR(DWORD, integer, 0)\n", + ) + .expect("numeric declarations"); + let formulas = FormulaTable::parse( + b"FUNCTION(literal, 7.0, )\nFUNCTION(add, source + 2.0, )\nFUNCTION(subtract, target - source, )\nFUNCTION(self, target + 1.0, )\nFUNCTION(integer, 10.75 + 0.5, )\nFUNCTION(exponent, target + 1e-2, )\n", + ); + let package = init_package(vec![ + init_handler19(), + init_formula_assignment(3, 0), + init_formula_assignment(3, 1), + init_formula_assignment(3, 2), + init_formula_assignment(3, 3), + init_formula_assignment(4, 4), + init_formula_assignment(3, 5), + ]); + + assert_eq!( + varset.resolve_init(&package, init_input(), Some(&formulas),), + Ok(ScriptInitResolution::Executed { + event_index: 0, + event_word: 0, + writes: vec![ + ScriptInitWrite { + index: 0, + value: VarSetDefault::Dword(10), + }, + ScriptInitWrite { + index: 1, + value: VarSetDefault::Dword(20), + }, + ScriptInitWrite { + index: 2, + value: VarSetDefault::Dword(30), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(7.0_f32.to_bits()), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(12.0_f32.to_bits()), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(2.0_f32.to_bits()), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(3.0_f32.to_bits()), + }, + ScriptInitWrite { + index: 4, + value: VarSetDefault::Dword(11), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(3.01_f32.to_bits()), + }, + ], + }) + ); + } + + #[test] + fn script_init_formula_atoms_use_native_token_and_name_rules() { + let varset = parse_varset( + b"VAR(float, dup, 1)\nVAR(float, dup, 2)\nVAR(float, Name, 3)\nVAR(float, name, 4)\nVAR(float, 1e-2, 41)\nVAR(float, A, 8)\nVAR(float, A1, 9)\nVAR(DWORD, high, 4294967295)\nVAR(float, large, 16777216)\nVAR(float, target, 0)\nVAR(DWORD, integer, 0)\n", + ) + .expect("numeric declarations"); + let formulas = FormulaTable::parse( + b"FUNCTION(duplicate, dup + 0, )\nFUNCTION(upper, Name + 0, )\nFUNCTION(lower, name + 0, )\nFUNCTION(numeric_name, 1e-2 + 0, )\nFUNCTION(long_operator_prefix, A1 + 0, )\nFUNCTION(unsigned_dword, high + 0, )\nFUNCTION(negative_exponent, 5e-2 + 2e-2, )\nFUNCTION(positive_exponent, 1e+2 + 2e-2, )\nFUNCTION(binary32_rounding, 16777216 + 1, )\nFUNCTION(cancellation, target - 16777215, )\nFUNCTION(dword_target, 10.75 + 0.5, )\nFUNCTION(single_variable, large, )\nFUNCTION(negative_dword_wrap, 2 - 3, )\nFUNCTION(unsigned_dword_wrap, high + 0, )\n", + ); + let package = init_package( + (0..14) + .map(|formula| { + init_formula_assignment( + if matches!(formula, 10 | 12 | 13) { + 10 + } else { + 9 + }, + formula, + ) + }) + .collect(), + ); + + let resolved = varset + .resolve_init( + &package, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ) + .expect("resolve Init") + .clone(); + assert_eq!( + resolved, + ScriptInitResolution::Executed { + event_index: 0, + event_word: 0, + writes: vec![ + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(1.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(3.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(4.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(41.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(9.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(4_294_967_296.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits((0.05_f32 + 0.02_f32).to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits((100.0_f32 + 0.02_f32).to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(16_777_216.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(1.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 10, + value: VarSetDefault::Dword(11) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(16_777_216.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 10, + value: VarSetDefault::Dword(u32::MAX) + }, + ScriptInitWrite { + index: 10, + value: VarSetDefault::Dword(0) + }, + ], + } + ); + assert!(formulas.evaluate_numeric_literal(0).is_err()); + } + + #[test] + fn script_init_rejects_unsupported_formula_variable_forms() { + let varset = parse_varset( + b"VAR(float, target, 1)\nVAR(float, A, 2)\nVAR(float, A1, 3)\nVAR(float, ^name, 4)\n", + ) + .expect("numeric declarations"); + for expression in [ + &b"target * 2"[..], + b"-1", + b"target + 1 + 2", + b"missing + 1", + b"A + 1", + b"^name + 1", + b"name\xc3\xa9 + 1", + ] { + let formulas = one_formula(expression); + let package = init_package(vec![init_formula_assignment(0, 0)]); + assert!( + matches!( + varset.resolve_init( + &package, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ), + Ok(ScriptInitResolution::Unsupported { + error: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 0, + .. + }, + .. + }) + ), + "accepted unsupported formula {:?}", + String::from_utf8_lossy(expression) + ); + } + + let mut inconsistent = varset; + inconsistent.declarations[0].default_value = VarSetDefault::Dword(1); + let formulas = one_formula(b"target + 1"); + let package = init_package(vec![init_formula_assignment(0, 0)]); + assert!(matches!( + inconsistent.resolve_init( + &package, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ), + Ok(ScriptInitResolution::Unsupported { + error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, .. }, + .. + }) + )); + + let mut inconsistent_target = + parse_varset(b"VAR(float, target, 1)\n").expect("numeric declaration"); + inconsistent_target.declarations[0].default_value = VarSetDefault::Dword(1); + let formulas = one_formula(b"2.0 + 1"); + assert!(matches!( + inconsistent_target.resolve_init( + &init_package(vec![init_formula_assignment(0, 0)]), + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ), + Ok(ScriptInitResolution::Unsupported { + error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, .. }, + .. + }) + )); + } + + #[test] + fn script_init_keeps_unsupported_formula_expressions_lazy_and_transactional() { + let varset = parse_varset(b"VAR(DWORD, target, 0)\nVAR(DWORD, integer, 0)\n") + .expect("numeric declaration"); + let formulas = + FormulaTable::parse(b"FUNCTION(unsupported, target * 2, )\nFUNCTION(literal, 7.5, )\n"); + let action = |formula_index| ScriptInstruction { + header_words: [u32::MAX, 1, u32::MAX, u32::MAX, 6, 0, formula_index], + references: Vec::new(), + }; + let package = |formula_index| ScriptPackage { + opcode_handler_count: 73, + events: vec![ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0, + instructions: vec![action(formula_index)], + }], + trailing_bytes: Vec::new(), + raw: std::sync::Arc::from(&b""[..]), + }; + let input = Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, }; assert_eq!( - resolve(instruction(0, u32::MAX)), - Ok(Handler19InitResolution::Unsupported { + varset.resolve_init(&package(1), input, Some(&formulas)), + Ok(ScriptInitResolution::Executed { + event_index: 0, + event_word: 0, + writes: vec![ScriptInitWrite { + index: 1, + value: VarSetDefault::Dword(7), + }], + }) + ); + assert_eq!( + varset.resolve_init(&package(0), input, Some(&formulas)), + Ok(ScriptInitResolution::Unsupported { event_index: 0, instruction_index: 0, - error: Handler19ResolveError::UnsupportedControlWord { found: 0 }, + error: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 0, + expression: b" target * 2".to_vec(), + }, }) ); + + let mut transactional = package(0); + transactional.events[0].instructions.insert(0, action(1)); assert_eq!( - resolve(instruction(u32::MAX, 2)), - Ok(Handler19InitResolution::Unsupported { + varset.resolve_init(&transactional, input, Some(&formulas)), + Ok(ScriptInitResolution::Unsupported { + event_index: 0, + instruction_index: 1, + error: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 0, + expression: b" target * 2".to_vec(), + }, + }) + ); + } + + #[test] + fn script_init_keeps_missing_table_and_bad_indices_typed() { + let varset = parse_varset(b"VAR(DWORD, integer, 0)\n").expect("numeric declaration"); + let instruction = |target, formula_index| ScriptInstruction { + header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, formula_index], + references: Vec::new(), + }; + let package = |target, formula_index| ScriptPackage { + opcode_handler_count: 73, + events: vec![ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0, + instructions: vec![instruction(target, formula_index)], + }], + trailing_bytes: Vec::new(), + raw: std::sync::Arc::from(&b""[..]), + }; + let input = Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }; + let formulas = FormulaTable::parse(b"FUNCTION(literal, 1, )\n"); + + assert_eq!( + varset.resolve_init(&package(0, 0), input, None), + Ok(ScriptInitResolution::Unsupported { event_index: 0, instruction_index: 0, - error: Handler19ResolveError::UnsupportedResultSave { found: 2 }, + error: ScriptInitResolveError::MissingFormulaTable, }) ); + assert_eq!( + varset.resolve_init(&package(1, 0), input, Some(&formulas)), + Err(ScriptInitResolveError::VarSetIndexOutOfBounds { + index: 1, + declarations: 1, + }) + ); + assert_eq!( + varset.resolve_init(&package(0, 1), input, Some(&formulas)), + Err(ScriptInitResolveError::FormulaIndexOutOfBounds { index: 1, rows: 1 }) + ); } #[test] diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index 54946e7..5e17a23 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -409,6 +409,13 @@ impl TerrainWorld { self.surfaces.len() } + /// Bounds of the indexed surface geometry, excluding unused source vertices. + #[must_use] + pub fn surface_bounds(&self) -> Option<[[f32; 3]; 2]> { + let root = self.surface_bvh.nodes.get(self.surface_bvh.root?)?; + Some([root.min, root.max]) + } + /// Returns the validated source mesh retained for renderer integration. /// /// Runtime collision queries use their own compact triangle representation; @@ -1215,6 +1222,15 @@ mod tests { } #[test] + fn surface_bounds_cover_query_geometry_and_ignore_unused_positions() { + let mut mesh = synthetic_land_mesh(); + mesh.positions.push([1000.0, -1000.0, 1000.0]); + let world = TerrainWorld::from_land_msh(&mesh).expect("world"); + assert_eq!(world.surface_bounds(), Some([[0.0; 3], [1.0, 1.0, 2.0]])); + assert_eq!(TerrainWorld::default().surface_bounds(), None); + } + + #[test] fn synthetic_surface_height_and_raycast_work() { let world = TerrainWorld::from_land_msh(&synthetic_land_mesh()).expect("world"); diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index eac2321..f587743 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -10,6 +10,49 @@ authoring API без динамического подтверждения. Не доказаны символами точные имена renderer vtable slots, полный набор CShade state transitions и окончательный порядок части transparent/FX/shadow subpasses. +После ограничения цели рельефом интерактивная камера проверяет движение +против каждого отрисовываемого корневого строения класса `FORT` и его +отрисовываемых компонентов. Для этого пути используются текущая Node38-поза +компонента и его преобразование присоединения. При попадании камера сохраняет +прежнюю позицию, но оставляет новый поворот. Так приложение связывает мировой +MSH-запрос со свободной preview-камерой; это не подтверждает алгоритм +стратегической или следящей камеры оригинала. + +Локальный MSH-запрос `query_slot_segment` сверён с оригиналом на фабричной +стене и дверном проёме. Runtime-адаптер `query_building_segment_world` теперь +повторяет helper batch bit `0x0008` через связанный узел, указанный raw-словом +`Batch20 +0x06`. При повторе он обходит Node38-поддерево: сначала допустимых +потомков, затем сам узел. Флаги ветвления сверены с оригиналом. Синтетические +тесты покрывают связи, отсутствие геометрии у промежуточного узла, маски, +равные попадания и предел в 200 пересечений. + +Игнорируемый тест приложения загружает настоящий PLANT 4 из AutoDemo и его +исходную CTLD-привязку Node3 (`frame_a = 0`, `frame_b = 1`, blend `0`). В +контроллере корневого PLANT нет записей tag-21 для открытия двери. В исходной +закрытой позе камера блокируется о дверь через обычный путь обновления +приложения. Для второго запроса тест вручную задаёт время `1` только Node3, +обновляет иерархию позы компонента и преобразование присоединения, затем +проверяет, что камера проходит через проём. Это подтверждает только подачу +выбранных поз в коллизию приложения: тест не запускает событие открытия, не +доказывает видимое открытие двери и не сравнивает полный игровой цикл с +оригиналом. + +Открыты выбор первого корня и правила состава узлов/slot-ов оригинального +мирового запроса, сравнение полного обхода с оригиналом, а также связь с +игровыми позами и событиями. Текущий путь приложения использует все +отрисовываемые компоненты строений `FORT`, но их выбор ещё не сопоставлен с +поведением оригинала для первого корня и всего мира. Совпадение со +стратегической и следящей камерами не подтверждено +([MSH](../reference/msh.md)). + +Кривые alpha и метрика расстояния порталов реализованы для MSH без type 16 +([описание кадра](../reference/render-frame.md#прозрачность-порталов-msh)). Native CPU clipping может выбрать другую первую вершину; +это приближение явно считается как `portal_fade_packet_selection_approx`. + +Данные type 16 ещё не декодированы. Внутренний ракурс «Конструктора» после +настоящего входа игрока подтверждает исчезновение чёрной поверхности в проходе; +для всей приёмки R2 ещё нужны пары обеих миссий на трёх дистанциях. + Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state log на минимальных сценах с одним типом материала. @@ -74,22 +117,124 @@ Iron3D и указатели `GetSuperAI` из `ai.dll` (таблица RVA `0x5 tie-векторы проверяют, как сам `FISTP` реагирует на каждый RC, но не измеряют control word процесса. -Отдельно проверены загрузка лицензированного AutoDemo в Rust и preview -лицензированной кампании. AutoDemo даёт raw TMA anchor-ы -`[(500.2857, 751.8217), (728.0, 449.0)]` и записи `[(500, 752, 0), -(728, 449, 1)]`; preview кампании сохраняет package и статус `Unsupported`, -не применяя частичные записи. Это подтверждает загрузчик и узкий Init resolver -Rust. +Лицензированный AutoDemo по-прежнему загружается с raw TMA anchor-ами +`[(500.2857, 751.8217), (728.0, 449.0)]` и записями `[(500, 752, 0), +(728, 449, 1)]`. Rust теперь также читает optional `.fml` sibling для каждого +script base и поддерживает узкий набор атомарных формул в первом `Init`. +Отсутствующий `.fml` не мешает пакету с одними `Handler(19)`; другие ошибки +чтения распространяются как VFS errors. Нативный конструктор показывает предупреждение при координате выше `10000`, но продолжает работу. Rust моделирует числовую запись, а не это UI-предупреждение; на первом `FISTP qword` он явно завершает загрузку ошибкой для NaN, infinity и значений вне signed-qword диапазона. В первом событии `Init` Rust поддерживает -только action-форму `Handler(19)` (`control word` и `result reference` равны -`0xffffffff`). Неизвестная инструкция даёт `Unsupported` в preview, не применяя -накопленные частичные записи; при полной загрузке это ошибка. Это ограничение -Rust исполнителя, не правило оригинального dispatcher-а. Ошибочные ссылки и -типы остаются ошибками в обоих режимах. +два доказанных действия: `Handler(19)` и formula assignment selector `-1` при +source `-1`, нулевой глубине условий и корректных индексах. У `Handler(19)` +control находится в `header_words[3]`, а `header_words[1]` — result-save +reference; оба равны `0xffffffff` в поддержанной форме. В дисковом заголовке +formula assignment `header_words[1]` — индекс целевой varset ячейки, +`header_words[2]` — source, а индекс formula row занимает `header_words[6]`; +индексы цели и формулы в native record находятся по смещениям `+0x08` и `+0x1c` +соответственно. Маппинг подтверждён +`ai.dll!FUN_10011b20` (RVA `0x11b20`) и исполнителем `FUN_10012020` (RVA +`0x12020`), SHA-256 `c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`. +`Handler(19)` (`FUN_1000aa38`, RVA `0xaa38`) использует references `0..2`; +`header_words[2]` его result-save guard-ом не проверяется. + +Formula table загружается из `<bundle-base>.fml`; native `FUN_100123b0` +(RVA `0x123b0`) проверяет ordinal, compiler `FUN_100152a0` (RVA `0x152a0`) +строит числовые, переменные и операторные узлы, а evaluator `FUN_10015b30` +(RVA `0x15b30`) в проверенных путях `+` и `-` считывает значения переменных и +сохраняет результаты как `f32` при x87 control word `0x007f`. В GOG-сборке +`ai.dll` с SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` поиск +переменной через varset vtable slot `+0x10` ведёт на RVA `0x16ad0`: сравнение +байтов точное, регистр учитывается, при повторах берётся первое объявление. +Getter `FUN_10013190` (RVA `0x13190`) читает type `3` как `f32`, type `5` как +беззнаковый `DWORD` и возвращает число через x87. Затем evaluator сохраняет +его как `f32` до операции. Оператор `+` +считает right+left, `-` — left-right; результат также сохраняется как `f32`. +Target type `3` сохраняет эти bits; type `5` проходит через `FUN_10012c00` +(RVA `0x12c00`) и `__ftol` (RVA `0x1df70`) к raw DWORD с усечением к нулю. +Изолированный x86 oracle на оригинальном `ai.dll` и настоящих ресурсах +подтвердил таблицы `tut1_pl2`/`scr_pl_1`/`scream` с +26/5/149 строками и 15 присваиваний: кампанийные `df0..df3` и `Single.01` +`dT8/dT9` получают `0`; `scream` задаёт `fDifficulty = 0.8f` и остальные +проверенные DWORD `0, 0, 1, 10, 0, 100, 400, 2`. + +Граница Rust остаётся узкой: один числовой токен либо ASCII-имя переменной, +или два таких атома с одним бинарным `+`/`-`. Имя проверяется раньше числового +преобразования, сравнивается с учётом регистра и выбирает первое совпадение; +публичный `FormulaTable::evaluate_numeric_literal` принимает только числовые +литералы. Временная копия значений начинается со значений по умолчанию и +обновляется после каждого успешного действия `Init`, поэтому следующая формула +видит предыдущие записи. Нет +поддержки неизвестных операторов, унарных форм, третьего атома, отсутствующих +или не-ASCII имён и source-copy ветви `source != 0xffffffff`; native source-copy +переносит всю запись varset. Preview сохраняет package, но не применяет частичные +writes; полный load отклоняет новый мир и сохраняет прежние world/RNG. Это +политика ограниченного Rust resolver-а, а не правило оригинального dispatcher-а. + +Ресурсные строки с переменными найдены в поздних событиях: `c1m3e.fml` row 19 +(`0.8 + fT`) вызывается из `Fort_Captured`, row 22 +(`dBuildingProtectHits - dT2`) — из `PBM_BUILDING_PROTECT_Start`, а `c1m4e.fml` +row 0 (`fTemp + 0.00001`) — из `PBM_BUILDING_INF_CAPTURE_Start`. Декодирование +этих `.scr` не показывает эти формулы в первом `Init`. Игнорируемая проверка +читает настоящие `.fml` и `varset.var`, но вставляет строки в синтетический +`Init`; она проверяет resolver, а не оригинальное исполнение. Одноразовый x86 +oracle на исходной GOG `ai.dll` с SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` проверил +13 отдельных присваиваний: 9 контролируемых выражений и 4 вызова с настоящими +строками `.fml` (`c1m3e` rows 19/22, `c1m4e` row 0 дважды подряд). В каждом векторе +native harness записывал вычисленный `f32`, типизированное raw-слово и getter; +scratch-проход через публичный Rust parser/resolver совпал со всеми 13 +векторами по `f32`-результату и raw-записи. Для native вызовов control word +временно принудительно устанавливался в `0x007f`: 87 наблюдений сохранили +`0x027f` на входе и после восстановления, +а canary-проверки прошли. Менялись только varset-копии в памяти. Это +подтверждает компиляцию формулы, локальное вычисление и typed writes для этих +входов, но не запуск исходного поиска и обхода события `Init`, исполнение поздних +событий или поведение живой миссии. + +Лицензированный test загружает одну первую кампанийную миссию +`CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma` после literal writes; это не проход +всей цепочки кампании. Обычная `Single.01` доходит до следующего blocker-а: +`Handler(43)` в clan 1, `Init` instruction 10. Rust не перескакивает его; +preview оставляет varset этого клана на defaults, а полный load завершает ошибкой +с rollback. В `fparkan-assets` теперь есть bounded TRF reader, pure ore/power +core и `ResearchCandidateSet::refresh_selection`: последний получает уже +упорядоченные entries и отдельный TRF-контекст, затем читает Unit DAT через VFS. +Он не строит AI-каталог и не исполняет script instruction. Runtime всё ещё не +создаёт владельца живого контекста, не передаёт реальные AI candidates и не +выбирает результат Handler(43). Это не общий dispatcher и не загрузка игры. + +Начальный массив слота AI `+0x38` теперь восстановлен отдельно от этого +исследовательского пути. В `iron3d.dll` SHA-256 +`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa` +`FUN_100392e0` (RVA `0x392e0`) вызывает слот для каждого значения счётчика +`second_resource.tag`. В `ai.dll` SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` метод +RVA `0x2010` передаёт `AI+0x83c` в `FUN_10002980` (RVA `0x2980`), где +`FUN_100048f0` (RVA `0x48f0`) добавляет переданный DWORD. Вызывающая сторона +повторно передаёт `0xffffffff`; эта функция не записывает значение обратно. +Поэтому подтверждён только начальный массив sentinel-слов. Неизвестно, какие +поздние обработчики читают эти ячейки и заменяет ли кто-либо их ID. В начальном +массиве нет назначенных ID объектов; дальнейший смысл этих слов пока не +установлен. Сам массив не является списком +`BehaviorDistributorState.registered_objects` и не определяет владельца +контекста исследований. + +Rust хранит этот снимок как `MissionAiStartupState` в состоянии загруженной +миссии. Полная загрузка проходит по всем TMA-кланам, включая кланы без пакета +сценария и без пути таблицы исследований; при предпросмотре список состояний AI +остаётся пустым. Знаковое поле `second_resource.tag` читается по сырым битам как +`u32`. Бюджет в 1 000 000 DWORD на миссию и выделение памяти с обработкой ошибки +защищают только память Rust; это ограничение не приписывается оригинальному +движку. Искусственные Rust-тесты проверяют обе формы TMA-клана, индекс клана, +независимые массивы, пустой путь сценария, предпросмотр, чтение тега как +беззнакового числа, лимит и откат при ошибке. Проверка на оригинальных ресурсах +для `Single.01` подтверждает состояния обоих кланов (`9/9`) до выполнения +`Init`; это не полный запуск миссии и не исполнение исходных DLL. Для GOG-сборки также найден статический host call path в `Init`. В `iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи @@ -169,6 +314,26 @@ Behavior (`Behavior.dll`, RVA `0x3600`) вызывает `World3D!stdGetGameMode с реальными интерфейсами ArealMap, нужными `FUN_10019e80`; полный Behavior tick flow и общий dispatcher событий также остаются открытыми. +`loaded_mission_behavior_control_groupings` публикует только начальную +группировку подготовленной Control-таблицы. Ветка с меткой `2` исходного +`Behavior.dll!FUN_1001be10` (RVA `0x1be10`) дополнительно вызывает +`FUN_1001b4b0` и обращается к свойствам через QI `0x204`, включая запросы с +номерами `0x700`, `6` и `0x400`. Результат `0x700` попадает в строку кандидата +по смещению `+0x3c`, результат `6` — в `+0x40`, а `0x400` может обнулить второе +поле. Их доменный смысл, множитель `0x700`, агрегаты Behavior QI `0x204` и +прочие числовые поля кандидата не восстановлены. Rust не подставляет множитель +`0x700`, нулевые агрегаты, выбранный индекс или эффект. Список для метки `30` не +встречается в ресурсах AutoDemo и проверяется только на искусственном наборе. + +Игнорируемая проверка AutoDemo сравнивает порядковые номера записей начальной +группировки, отдельно выведенные по снимку текущих меток дескрипторов, с +состоянием Rust после полной загрузки. Это подтверждает согласованность разбора +подготовленной таблицы и правила группировки на этих ресурсах, но не является +захватом состояния Behavior оригинала и не устанавливает, когда список +прикрепляется или обновляется в живом объекте. Полный штатный путь загрузки и +запуска, агрегаты Behavior QI `0x204`, обновление после изменений Control, +`Handler(43)` и полный игровой цикл остаются отдельными границами. + Начальные свойства `CurrentOre`/`MaximumOre` восстановлены отдельно от регулярного обмена. В проверенных `Behavior.dll` (SHA-256 `ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) RVA @@ -312,7 +477,9 @@ Global Control getters `1/2` и setter `1` проверены в native x86 orac этого класса native oracle не запускал. Rust API обращается к строкам проекции конкретного живого `ObjectHandle`; это запрос к уже загруженной таблице, а не исполнение Control или Behavior tick. Setter `17`, остальные factory tags и -внешние эффекты их методов не включены. Для tag `9` selector `0` использует +внешние эффекты их методов не включены. Структурное чтение записи CTLD с +меткой 17 ещё не восстанавливает её обработчики и игровое поведение. +Для tag `9` selector `0` использует отдельный scale `+0x11c`; остальные поддержанные классы используют коэффициент напрямую. Пустой набор и нулевой знаменатель остаются native no-result, а неподдержанный factory tag и расчётный диапазон произведения/отношения за @@ -356,6 +523,41 @@ MSH-часть проверки подтверждает начальный сн остаётся статическим выводом по RVA `AniMesh.dll+0xa460`, а последующие изменения в полном Control/animation tick-е не проверялись. +Устройство фабрики `2` через selector `6` читает `+0x174` только при текущем descriptor tag `2`, +затем проверяет, что устройство создано фабрикой `2` и поле доступно. Значение +должно быть конечным нормальным числом или нулём любого знака. Строки в +`[27..35)` и `[35..43)` читаются до первого NUL: хвост игнорируется, +ASCII-регистр не важен. После изменения пустая строка оставляет старые биты; +пустая пара конструктора уже совпадает и не вызывает пересчёт. Проверка не +использует список имён: нужен один корневой mesh `BULL` из `ObjectsRegistry`, +без дочерних компонентов и записей timeline; все типизированные записи должны +иметь tag `17`, а эффекты и таблица NDP — разбираться полностью. Число строк +NDP произвольно, включая пустую таблицу. + +Сканер возвращает коды выбранной начальной группы в исходном порядке. Допустимы +`1/4/12`. `Some([])` означает, что по полному разбору данных команд нет: группа +отсутствует, пуста или сырое беззнаковое значение индекса не меньше числа +групп. `None` означает неподдержанный либо неполный разбор; повреждённые данные +дают ошибку. Для выбранного opcode `4` нужны FXID-типы `2/3/4/5/7/8/9` и +допустимые CPT-смещения внутри компонента; типы `1/6/10` остаются недоступными. +Непредставленный ключ или число не превращаются в ноль. Если распознанный +ресурс повреждён, новая миссия завершается ошибкой до публикации, сохраняя +прежние мир и RNG. + +Загрузка лицензированной AutoDemo и Rust-запрос selector `6` охватили 12 +устройств и семь пар с битовыми значениями: +`bb_m_01=43f98000`, `bm_m_01=44228000`, `bl_l_01=436a0000`, +`br_b_01=44c80000`, `bf_b_01=443b8000`, `bl_m_01=443b4000`, +`bm_l_01=43340000`. Все семь значений совпали с замороженными арифметическими +векторами. Пятнадцать изолированных вызовов selector `35` оригинальной DLL +(включая семь однострочных BULL-таблиц) проверили арифметику при x87 control +word `0x007f`, но не создавали полный native World. Таблица Control-устройств +Rust по-прежнему строится при полной загрузке +Unit DAT. CPU-проекция не воспроизводит временное создание объектов оригинала и +его побочные эффекты FX, звука и RNG, размещение в World или следующий Control +tick. Полное размещение объектов, игровой tick, Handler 43 и другие +неподдержанные форматы остаются открытыми. + Для provider-строки Rust хранит amount `+0x08`, накопитель `+0x10`, обработанное количество `+0x0c`, долю `+0x14`, стадию `+0x18` и flags `+0x28` раздельно. Инициализатор `Control.dll+0xf940` кладёт результат selector `3` в текущее и @@ -419,12 +621,48 @@ callbacks. Licensed двухстадийный узел шахты подтвеÑ новый API при доле `0.5`, без FX и window path. Гибель root/critical узла и неизвестные flags/aggregate также остаются явными ошибками. -Rust query обращается к загруженному объекту и поддерживает `0xe00` только для -текущего descriptor tag `26` с factory tag `26`; другие активные typed tags и -factory paths остаются явными границами. Запрос читает текущее type-1 значение. -Licensed `min_l_n1.dat` подтверждает изменение `0xe00` и Behavior `0x1002` с -`1.0` до `0.5` после явного уменьшения количества вдвое; чтение получает -текущее значение provider, который также меняет явный бюджетный шаг. +Rust query обращается к загруженному объекту и поддерживает `0xe00` для пар +текущего descriptor tag и factory tag `(5,5)`, `(5,26)`, `(26,5)` и `(26,26)`; +прочие специализированные tag/factory пути остаются явными границами. Запрос +читает текущее type-1 значение provider. Licensed `min_l_n1.dat` подтверждает +изменение `0xe00` и Behavior `0x1002` с `1.0` до `0.5` после явного уменьшения +количества вдвое; чтение получает текущее значение provider, который также +меняет явный бюджетный шаг. + +Indexed selector `0x400` в том же Control IID `0x204` возвращает текущее type-1 +значение строки provider, выбранной через key устройства, при любом descriptor +и factory tag. Rust принимает конечное normal-or-zero значение, включая оба +знака нуля, и сохраняет его биты; недоступная таблица, отсутствующий key и +значения вне этой численной области дают типизированную ошибку. В оригинальном +`Control.dll` с SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` ветвь +`FUN_1002bb40` (RVA `0x2bc3c`) читает provider pointer `device+0x44` и key +`device+0x04`, вызывает provider slot `+0x0c` в режиме `1` и не проверяет +descriptor/factory tag. Rust-проекция читает сохранённый снимок; она не вычисляет +value из amount, maximum, coefficients или множителя `M`. Native selector-cell +alias/fallback за пределами валидной записи и поведение native при невалидном +key остаются неисследованными; Rust не подставляет для них `0.0` или `None`. +Это покрывает перечисленные query-пути. Полный интерфейс IID `0x204`, его +взаимодействие со всеми Behavior-вызовами и полный игровой tick остаются за +границей подтверждённого поведения. + +Объём свидетельств для `0x400` ограничен и состоит из трёх частей. Native +oracle на настоящем `Control.dll` проверил шесть выходов по raw bits для +ограждённого синтетического owner/provider table; он вызвал indexed query и +реальный provider getter, но не создавал оригинальные factory-объекты. Матрица +выходов находится в [техническом разборе](script-vm.md#индексный-control-getter-сырой-селектор-0x400). +Независимый разбор AutoDemo обнаружил восемь таблиц и 100 устройств с +доступными ключами, но все выбранные provider values равны `1.0`; такой набор +подтверждает загрузку и область ключей, однако не различает, какую именно +строку выбирает алгоритм. Игнорируемый Rust-тест опрашивает эти 100 устройств и +проверяет те же биты, не выдавая равенство ресурсных данных за доказательство +индексирования. + +Вместе native-векторы и resource acceptance не подтверждают состояние +оригинальных factory instances, полный контракт IID `0x204`, полный +Behavior-call flow или живую миссию/gameplay. Поведение при ошибочных native +указателях и ключах, а также selector-cell alias/fallback вне валидной строки +по-прежнему не описано; Rust сохраняет typed errors на этой границе. Native oracle проверил изолированные пути распределения, обновления класса `4`, стадийную арифметику `0x11220`, обход дочерних provider-строк `0x11130` на interleaved parents `[-1, 0, 0, 1, 3]` с callback-порядком `1, 3, 4, 2` и @@ -562,16 +800,19 @@ resource-device значения во время игры. `0x5780/0x57c0` участвуют в векторе; Rust воспроизводит его raw output bits и отдельные cache, order, clamp и signed-zero случаи. -Для обычной начальной загрузки scale-input тоже закрыт. AniMesh constructor -оставляет `+0x158/+0x15c/+0x160` равными `1.0`; перед Control event `4` World3D -проходит Agent event `0`, AniMesh RVA `0x1a80` вызывает базовый return `0x17df0` -и передаёт event `4/null` модели, FX, CollManager и CollObject. Их проверенные -ветви `0x75b0/0x6fc0`, Effect RVA `0x3d30`, Control RVA `0x1ce00` и `0x1fe00` -не вызывают AniMesh setter `0x14770` и не меняют outer scale. Поэтому FAC0 в -стандартном `LoadAgent` получает `[1, 1, 1]`. Другой маршрут -`AddNewObjectToGame`/mirror к setter и последующие placement/tick остаются -отдельными границами. В synthetic x86-векторе по-прежнему явно заданы `[2,3,5]`; -он не является источником scale для полного load. +Для обычной начальной загрузки входной масштаб единичный. Конструктор модели +AniMesh RVA `0x68a0` задаёт `Model+0x158/+0x15c/+0x160 = 1.0`; +`Agent+0x158` хранит указатель на отдельный Control. Перед Control event `4` +World3D проходит Agent event `0`: AniMesh RVA `0x1a80` вызывает базовый return +`0x17df0` и передаёт event `4/null` модели, FX, CollManager и CollObject. +Проверенные ветви модели `0x75b0/0x6fc0`, Effect `0x3d30` и CollManager +`0x1ce00` не меняют масштаб. CollObject получает событие через основной +интерфейс `0x3c2fc`, slot `+0x08 -> 0x1f530`: он кэширует host-интерфейсы, +а необязательный вызов CollManager `0x1c330` сохраняет только указатель +`CollObject+0x20` в `CollManager+0x3c`. Поэтому FAC0 получает `[1, 1, 1]`. +Отдельные пути `AddNewObjectToGame`/mirror и последующие placement/tick остаются +за этой границей. В synthetic x86-векторе scales `[2,3,5]` заданы отдельно. +Подробная привязка интерфейсов приведена в [разборе Script VM](script-vm.md). Смысл `Slot68 +0x30/+0x34`, tag-27 cache DWORD, owner accumulators `+0x530`, `+0x534`, `+0x538` и результата mutable word `26` не имеет доказанных единиц @@ -644,26 +885,142 @@ Native interoperability подтверждается только успешны Граница shell подтверждена exports `createShell/getIShell`, `IGUIServer`, верхнеуровневым UI-pass и файлами `ui/*.cfg`, `DATA/TextRes.cfg`, -`gamefont.rlb` и `sprites.lib`. RsLi framing библиотек закрыт, но widget tree, -layout rules, glyph metrics, sprite command semantics, focus/navigation и HUD -state machine пока не восстановлены до field-level спецификации. - -Закрывающий эксперимент: трассировать загрузку `shell_ctrls.cfg`, -`menu_resources.cfg`, `cursor.cfg`, `game_resources.cfg` и `hq.cfg`, сопоставить -GUI object factories и снять command/event captures для меню, HUD, briefing и -диалогов. +`gamefont.rlb` и `sprites.lib`. Для полной GOG-сборки подтверждены раздельные +форматы: `gamefont.rlb` читается общим RsLi reader, но его native consumer этим +не установлен; для меню подтверждена цепочка `menu_resources.cfg` → NRes +`font.lib` → native lookup `MENU_FONT` с получением реального font-объекта. +Reader `fparkan-font` разбирает 20-байтовый Tfnt prefix, 256 записей фиксированного +шага и передаёт вложенный Texm общему reader-у. Native consumer подтверждает +два целочисленных параметра, per-byte spacing и signed advance; смысл трёх glyph DWORD +и поля заголовка по `0x0c` пока не установлен. Это не реализует выбор шрифта, текстовый +layout или glyph drawing. `menu_resources.cfg` и шесть вариантов +`MENU_FONT`/`GAME_FONT` сопоставлены с NRes-записями в +[разборе shell и шрифтов](ui-shell.md). Также остаются открытыми выбор dimension +на фактическом дисплее `1280x1024`, widget tree, layout rules, sprite command +semantics, focus/navigation и HUD state machine. + +Закрывающий эксперимент: сопоставить оставшиеся детали `shell_ctrls.cfg`, +`cursor.cfg`, `game_resources.cfg` и `hq.cfg` с GUI object factories и снять +command/event captures для меню, HUD, briefing и диалогов. ## Research, economy and properties -Экспорты `LoadResearch`, `CalcFullResearchCost`, TRF/preload resources и TMA -properties доказывают отдельный слой исследований, стоимости, добычи и -производственных параметров. Формулы стоимости, dependency graph технологий, -inventory/economy transitions и точная типизация всех 16-byte property values -не закрыты. - -Закрывающий эксперимент: сопоставить research functions с ресурсами и UI, -снять изменения state на контролируемых покупках/исследованиях и построить -typed schema свойств по consumers, а не по одному имени. +Для GOG `ArealMap.dll` SHA-256 +`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea` +`CalcFullResearchCost` по RVA `0x16280` имеет подтверждённый ABI +stdcall `[context, candidate, out1, out2]`, `RET 0x10`, и восстановленный +алгоритм для одного candidate tree: zero-kind subtree pruning, direct preorder, +общая дедупликация, ordered DFS по `TRF2`/`TRF3`, raw gates из `TRF1` и +последовательное `f32` сложение. Принятый набор из 23 synthetic native cases +включал Calc и два AI predicate; для контролируемых сравнений x87 word был +установлен в `0x007f`. У набора synthetic context, поэтому он не измеряет +реальную миссионную стоимость. + +Отдельный прямой вызов chooser-а `ai.dll` SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` +`FUN_10010f90` (RVA `0x10f90`) прошёл 3 guard-case и frozen-матрицу из 15 +проверок при заданном порядке кандидатов. Тот же код использует конструктор RVA +`0x10480`, parser первого Unit DAT дерева RVA `0x102c0` и предикаты RVA +`0x115f0`/`0x11530`. Wrapper Handler(43) по RVA `0xd54a` и catalog loader +`0x104e0` не вызывались. Эта матрица не подтверждает полный Handler(43), +миссионный источник списка или штатный порядок AI-каталога. + +`fparkan-assets` декодирует используемые NRes chunks, сохраняя сырые TRF0 words +и непрозрачные данные. Native cost outputs сопоставлены только с двумя raw +words: out1 складывает TRF0 word 1 и подписан как ore, out2 складывает word 0 +и подписан как power. Смыслы остальных полей полного 32-byte callback record +не установлены. Non-ASCII case folding research keys также остаётся открытым; +Rust явно возвращает unsupported для таких сравнений. + +Rust `ResearchCandidateSet` получает ordered entries, а TRF-context передаётся +отдельно. Ошибка чтения VFS, неверный Unit DAT magic или неполное первое +дерево пропускают entry; missing key даёт false predicate. Cost вызывается и +для пустой таблицы: он возвращает `EmptyResearchTable`, после чего этот entry +пропускается с сохранением второго status. +Другие ошибки таблицы и +`UnsupportedNonAsciiLookup` остаются явными Result-ошибками. Уже записанные +statuses и выбранный индекс на ошибке не откатываются; это политика Rust API, +не доказательство полного native error ABI. + +Два Rust-предиката повторно обходят один декодированный tree с той же +immutable TRF-таблицей. Общий helper может остановить второй проход при первом +ложном флаге; native RVA `0x11530` продолжает обход и накапливает +неиспользуемые float sums. Первый проход уже проверил ключи всех enabled +компонентов, поэтому подтверждается только эквивалентность state/selection +для такого входа, не идентичность callback trace или вычисления этих sums. + +Статический перенос владельца исследовательского контекста подтверждён по трём +GOG-модулям. В `iron3d.dll` (SHA-256 +`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`) +`FUN_1005ff40` (RVA `0x5ff40`) передаёт индекс клана в `FUN_100392e0` (RVA +`0x392e0`), где он сохраняется в `row+0x1c` и копируется в `descriptor[0]`. +Thunk RVA `0xcd0a0` ведёт к `World3D!GetNetPlayerNum` (RVA `0x9110`), а RVA +`0xcd08e` — к `World3D!AddNewObjectToGame` (RVA `0x8310`); SHA-256 +`World3D.dll`: `17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`. +Вызов getter-а без аргументов завершается обычным `RET`; его результат становится +arg1 следующего вызова. `AddNewObjectToGame` принимает девять DWORD, включая +указатель на descriptor в arg9, и очищает стек через `RET 0x24`. RVA `0x83e9` +читает arg9; ветвь class `0xb`/QI `0x502` на RVA `0x853e` загружает +`descriptor[0]` и передаёт его в слот `+0x5c` на RVA `0x8544`; +`MisLoad.dll` (SHA-256 +`c607762bb67db14ca530927c8e1347339b62b0a163ac90c708ee83142292e2bf`) +`FUN_10002a30` (RVA `0x2a30`, `RET 8`) записывает его в `inner+8`. TMA tag +`row+0x20` ограничивает число вызовов AI slot `+0x38`, но не задаёт владельца. + +Создание AI не фильтруется по наличию сценарного пути. В `iron3d.dll` SHA-256 +`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa` +`FUN_1005ff40` (RVA `0x5ff40`) проходит записи кланов и вызывает для каждой +`FUN_100392e0` (RVA `0x392e0`); в `ai.dll` SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` +`CreateSuperAI` (RVA `0xf710`) выделяет AI и вызывает конструктор по RVA +`0x1000` без проверки пути сценария. Создание исследовательского контекста +следует за успешным созданием AI и требует включённого флага создания +исследований и непустого `second_resource.path` до первого NUL. Переданный в +`descriptor[0]` индекс клана, а не `second_resource.tag`, задаёт владельца; +тег остаётся счётчиком начальных вызовов AI slot `+0x38`. + +В Rust эта граница представлена только загрузкой таблиц. Полный +`load_mission` требует явный `MissionStartupInput { queue_time_ms, +research_creation_gate }`; этот флаг не выводится из `MissionMode` или имени +пути. Если флаг равен `true`, загрузчик проходит по существующим +`MissionAiStartupState` всех TMA-кланов, в том числе без сценарного пакета, +читает их `second_resource.path` до первого NUL и отклоняет не-ASCII путь. +Пустая строка пропускается; выключенный флаг и статический preview не читают +TRF. На каждого +владельца сохраняется отдельный `ResearchTrfDocument` с нормализованным путём, +даже когда разные кланы указывают на один файл. Обычные тесты на искусственных +данных проверяют владение у кланов со сценариями и без них, повторное +декодирование, пропуски, ошибки и откат; игнорируемая проверка оригинальных +ресурсов связывает реальные `Campaign.00/Mission.01` пары путей с индексами владельцев. +Rust пока сохраняет только декодированные данные и связь с владельцем: он не +создаёт полный исследовательский контекст, не строит AI-каталог и не исполняет +`Handler(43)`. + +Нативный metadata loader нельзя считать простым копированием таблицы. В GOG +`Behavior.dll` (SHA-256 +`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) selector +`0x204` по RVA `0xa490` обновляет сохранённое float-значение `+0x678`. В GOG `Control.dll` +(SHA-256 `bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) +selector `0x36` проходит цепочку RVA `0x31af0` → `0xe6c0` и записывает в общую +ячейку памяти по RVA `0x434bc`; selector `0x91` выполняет одноразовую +инициализацию, только если функция ещё не выполнила её. В Rust пока хранятся +только декодированные TRF-данные и сведения о владельце: эти побочные записи, +очередь и lifetime контекста не моделируются. + +Открыты runtime-выполнение Handler(43), создание и lifetime research context, +включение factory-цепочки в полный штатный startup миссии, построение AI +candidate list и штатный порядок кандидатов, live x87 policy, +inventory/economy transitions и точная типизация всех 16-byte TMA property +values. Strict ore comparison и выбор первого кандидата при равенстве известны +только для supplied-order chooser. Ignored acceptance проверяет реальные Part 1 +AI DAT prefix и `scream.trf`, но byte-sorted порядок не заявляется как native +catalog order и не сравнивает цену с вызовом chooser-а в mission context. + +Для проверки оставшихся границ нужно на живой миссии установить, проходит ли +штатный `Init` по статически восстановленной передаче owner, зафиксировать +создание и lifetime исследовательского контекста и сравнить порядок кандидатов +и cost outputs оригинала с Rust. Покупки, inventory transitions и typed +property schema требуют отдельных контролируемых исследований. ## Rare branches diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 3de442d..f7f8b97 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -16,21 +16,78 @@ handlers, второй — числом event records. Каждый event хра `name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных records. Вложенный record сохраняет семь `u32` header words (в disk order), список `u32` references после шестого header word и trailing seventh word. -Никакой из этих words ещё не получает semantic name. `.fml` — текстовый +Семантика остальных слов остаётся открытой; поля control и result-save описаны +в узком `Init` contract ниже. `.fml` — текстовый symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaults. `fparkan-script::parse_varset` уже читает подтверждённые numeric `VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и -`FUNCTION(...)` пока сохранены за границей этого numeric contract; +general `FUNCTION(...)` evaluation остаются вне восстановленного контракта, +но принятые строки сохраняются для ограниченного поднабора формул первого `Init`; GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration: 31 `float` и 200 `DWORD` (от `f0` до `fY`); loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при `not found` откатывается к этому shared file. Runtime повторяет данный порядок -транзакционно и публикует selected `MissionScriptVarSet` с путём/provenance, но -ещё не исполняет declarations как VM state; +транзакционно, создаёт typed varset cells для кланов и применяет поддержанные +записи первого `Init`. Это начальное состояние сценария, а не исполнение общего +VM state; `.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь consumer-inferred. +### Исследовательский профиль TRF и стоимость кандидата + +Для одной ветви `Handler(43)` уже восстановлен путь вычисления стоимости. +Результат привязан к GOG `ArealMap.dll`, SHA-256 +`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`, +`CalcFullResearchCost` по RVA `0x16280`. Реальный ABI — четыре аргумента +stdcall на стеке: `[context, candidate, out1, out2]`; функция снимает с него +`0x10` байт и не принимает `candidate` через `ECX`. + +Источник этих таблиц привязан к GOG `MisLoad.dll`, SHA-256 +`c607762bb67db14ca530927c8e1347339b62b0a163ac90c708ee83142292e2bf`. +`FUN_10002fe0` (RVA `0x2fe0`) загружает required TRF chunks. Внутренний +research context разрешает ключ через `FUN_10002a40` (RVA `0x2a40`), выдаёт +32-byte row record через `FUN_10002aa0` (RVA `0x2aa0`) и возвращает ordered +dependency array через `FUN_10002cf0` (RVA `0x2cf0`). Эти методы соответствуют +виртуальным слотам context `+0x08`, `+0x0c` и `+0x24`; native Calc получает их +через таблицу вызовов, а не напрямую из файлов. + +`ResearchTrfDocument` в `fparkan-assets` повторно использует NRes reader и +сохраняет исходный архив. `TRF0.attr1` задаёт число строк по 40 байт; native +загрузчик требует `TRF0.attr2 == 3`. `TRF1` задаёт один флаговый byte на строку: +Rust воспроизводит native zero-fill при коротком payload и отклоняет более +длинный. `TRFB.attr1` задаёт пары little-endian `(u16 name_offset, u16 row)`; +имя берётся из NUL-terminated pool `TRF6`. Поиск идёт по исходному порядку и +берёт первую ASCII-case-insensitive пару. Связь строки с парой использует +только младшие 16 бит сырого слова `TRF0` по смещению `+0x20`. + +Две таблицы зависимостей имеют собственные размеры: `TRF2` считает группы, +`TRF3` содержит их плоский упорядоченный поток индексов; `TRF4`/`TRF5` образуют +отдельную пару. Только `TRF2`/`TRF3` участвуют в `CalcFullResearchCost`. +Отсутствующий `TRF3` или `TRF5` означает пустой поток лишь при нулевой сумме +счётчиков соответствующей таблицы. Required `TRF7`–`TRFA` остаются +непрозрачными. Reader проверяет фактически читаемые срезы, строки, ссылки и +размеры выделений для памяти Rust; это дополнительная безопасность, а не +утверждение, что native loader отвергает те же плохие файлы. + +Из флагового byte `TRF1` native provider строит слова record `+0`, `+4` и `+8` +из битов `(1, 0, 2)`. `CalcFullResearchCost` использует первые два как raw gate, +не присваивая им общего игрового имени: `+0` определяет, добавлять ли строку, +`+4` — раскрывать ли её зависимости. Нулевой `kind` компонента Unit DAT +отсекает весь узел с потомками. Иначе дерево сканируется в preorder; найденные +строки добавляются один раз, затем для каждой прямой строки выполняется +упорядоченный DFS по `TRF2`/`TRF3`. Общая таблица посещений завершает циклы и +удаляет повторы. + +По debug-строкам `out1` — ore и суммирует `TRF0` word 1, а `out2` — power и +суммирует word 0. Native код хранит каждый шаг суммы как `f32`; один cost-вектор +`1, 2^24, -2^24` даёт первый результат `0`, а в другом порядке +`2^24, -2^24, 1` даёт `1`. Rust core сохраняет тот же порядок накопления. В +принятом наборе из 23 синтетических вызовов оригинальных DLL были случаи +`CalcFullResearchCost` и два AI-предиката; x87 control word был установлен в +`0x007f` для контролируемых сравнений. Набор проверяет выбранные ветви на +синтетическом контексте, но не использует реальный контекст миссии. + ## Безопасная модель исполнения Целевая VM должна разделять immutable package (bytecode, symbols, events, @@ -282,11 +339,15 @@ TMA properties остаются four raw `u32` words плюс имя, пока c сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state, `ClanID`, ore, speed и free-time properties. -Research/economy работают в simulation: `LoadResearch` и -`CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный -layout prerequisites/modifiers/unlocks. Formula evaluator требует strict -grammar/version, typed operands, deterministic numeric policy, bounded stack и -явных errors; x87-compatible rounding нужен там, где оно выбирает ветку. +Для одного кандидата и одной research-таблицы порядок prerequisite-closure, +дедупликация и две выходные стоимости `CalcFullResearchCost` восстановлены и +проверены отдельно, как описано в +[исследовательском профиле TRF](#исследовательский-профиль-trf-и-стоимость-кандидата). +Это не закрывает production-modifier/unlock data, экономические переходы или +полное исполнение Handler(43). Общий evaluator формул всё ещё требует +versioned grammar, typed operands, deterministic numeric policy, bounded stack +и явных errors; x87-compatible rounding нужен там, где оно выбирает ветку. +Описанный ниже literal-путь первого `Init` не закрывает общий evaluator. ### Первый фрагмент dispatcher-а: `Init` и Handler(19) @@ -302,10 +363,21 @@ image base `0x10000000`. В ней `FUN_10011f40` (RVA `0x11f40`) ищет имÑ `FUN_10011f10` передаёт выбранный индекс внутреннему циклу `FUN_10011fb0` (RVA `0x11fb0`). Цикл начинает с первой вложенной инструкции, сбрасывает индекс инструкции и глубину стека условий, затем идёт по записям в порядке файла. -В исходном `default.scr` событие `Init` содержит одну инструкцию selector `19`, -control word `0xffffffff` (действие) и result reference `0xffffffff` (результат -не сохраняется). Это наблюдаемая форма пакета; поведение оригинала для остальных -форм здесь не утверждается. +Читатель `FUN_10011b20` (RVA `0x11b20`) переставляет семь disk header words в +runtime record: `[0, 1, 2, 3, 4, 5, 6]` переходят в offsets +`[+0, +8, +0x0c, +4, +0x10, +0x18, +0x1c]`; указатель references лежит в `+0x14`. +Исполнитель `FUN_10012020` (RVA `0x12020`) берёт control из runtime `+0x04`, то +есть из `header_words[3]`, а result-save reference — из runtime `+0x08`, то есть +из `header_words[1]`. Disk word `header_words[2]` попадает в `+0x0c` и участвует +в особой ветви selector `-1`, но не является result-save полем `Handler(19)`. + +В исходном `default.scr` событие `Init` содержит действие selector `19` с +заголовком `[19, 0xffffffff, 0xffffffff, 0xffffffff, 6, 3, 0xffffffff]` и +references `224, 225, 226`. Для этого действия `header_words[3] = 0xffffffff` +(action control), а `header_words[1] = 0xffffffff` (результат не сохраняется). +Одинаковое значение в `header_words[2]` — лишь содержимое этой записи, оно не +проверяется guard-ом `Handler(19)`. Это наблюдаемая форма пакета; поведение +оригинала для остальных форм здесь не утверждается. В GOG `default.scr` события `Init` в двух AutoDemo bundles имеют по одной такой инструкции. Её ссылки `224`, `225`, `226` ведут к объявлениям `DWORD` @@ -350,10 +422,15 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D показывает исходное предупреждение `MessageBoxA`; оно находится за пределами этого числового resolver-а. -Ограниченный Rust-исполнитель `VarSet::resolve_handler19_init` ищет только -первое событие `Init`, проходит его инструкции по порядку и возвращает -упорядоченные записи. Runtime применяет их к отдельной копии varset выбранного -клана лишь после успешного прохода всего события. Отсутствующий `Init` даёт +Ограниченный Rust-исполнитель `VarSet::resolve_init` ищет первое событие +`Init` и проходит его инструкции по порядку. Он возвращает записи для +поддержанного `Handler(19)` и формул с одним числовым или переменным атомом либо +с двумя такими атомами, соединёнными одним бинарным `+` или `-`; прочая форма +останавливает preview на `Unsupported`. Runtime начинает вычисление с копии +defaults и записывает в неё каждый успешно разрешённый шаг, поэтому поздняя +формула видит прежние записи этого события. Упорядоченные записи попадают в +состояние клана только после успешного прохода всего `Init`. Отсутствующий +`Init` даёт статус `Missing` и не создаёт записей инициализации; пустой первый `Init` даёт `Executed` без записей. Если форма инструкции пока не поддержана, package остаётся доступен в preview режиме со статусом `Unsupported`, а default-значения @@ -363,6 +440,131 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D Это правила ограниченного Rust-исполнителя, а не обнаруженное поведение оригинального dispatcher-а. +#### Литералы и простые формулы `.fml` в `Init` + +В той же сборке `ai.dll` (`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`) +загрузчик `FUN_10001000` открывает `.fml` рядом с базой каждого `.scr`. +`FUN_10018f40` читает строки в таблицу `FUNCTION`; vtable slot `+0x18` +компилирует сохранённые строки через `FUN_100152a0` (RVA `0x152a0`). +Индексы формул — порядковые номера принятых строк, поэтому строки после +используемого номера тоже остаются в таблице. Native reader обрабатывает +независимые чанки до 255 байт, включая LF; CRLF нормализуется до LF, `Ctrl-Z` +завершает ввод, а NUL скрывает остаток своего чанка для C-string поиска. +Искатель учитывает регистр, ищет подстроку `FUNCTION` и независимо выбирает +первую `(` и запятые в видимом чанке. Это permissive framing, а не строгий +разбор сбалансированного вызова. Rust сохраняет принятые записи в этом порядке +и разбирает выражение только при ссылке из `Init`; отсутствующая соседняя `.fml` +не мешает одному лишь `Handler(19)`, но ошибка чтения, отличная от `NotFound`, +остаётся ошибкой VFS. + +Native selector `-1` при source `-1` и нулевой глубине условий выбирает +formula assignment. В дисковом заголовке `.scr` `header_words[1]` — индекс +целевой varset-ячейки, а `header_words[6]` — индекс formula row; соответствующие +поля native record находятся по смещениям `+0x08` и `+0x1c` соответственно. +`FUN_100123b0` (RVA `0x123b0`) проверяет индекс строки. Компилятор +`FUN_100152a0` (RVA `0x152a0`) создаёт +числовые узлы, узлы переменных и узлы операторов; числовой prefix проходит через +`FUN_10020af0` (RVA `0x20af0`) как `double`, после чего сохраняется в узле как +`float`. Для переменной компилятор вызывает vtable slot `+0x10` таблицы varset; +в проверенной GOG-сборке `ai.dll` этот вызов ведёт к поиску по RVA `0x16ad0`. +Поиск сравнивает байты имени с учётом регистра и возвращает первое совпадение. +`FUN_10015b30` (RVA `0x15b30`) читает числовой узел как `f32`, а переменную — +через `FUN_10013190` (RVA `0x13190`): type `3` даёт `float`, type `5` — +беззнаковый `DWORD`. Getter возвращает число через x87; evaluator сохраняет +его как `f32` перед операцией. Оператор `+` складывает правый +и левый операнды, а `-` вычисляет левый минус правый; после узла результат +сохраняется как `f32`. `FUN_10013650` передаёт итог типизированной ячейке через +`FUN_10012c00` (RVA `0x12c00`). + +Rust в первом `Init` принимает один числовой токен или имя ASCII-переменной, +а также ровно два таких атома с одним бинарным `+` либо `-`. Например, +`source + 2.0` и `target - source` читают значения из defaults или из предыдущих +успешных записей события. Имя ищется раньше числового преобразования, поэтому +объявление с именем `1e-2` перекрывает такую числовую запись; сравнение имён +чувствительно к регистру, а повтор объявлений выбирает первое. `DWORD` читается +как беззнаковое число и округляется до `f32` до операции. Публичный +`FormulaTable::evaluate_numeric_literal` по-прежнему принимает только литералы. +Ведущие unary `+`/`-`, `*`, другие операторы, третий атом, отсутствующие или +не-ASCII имена и зарезервированные одиночные операторы остаются `Unsupported`; +так же не поддержана ветвь копирования `source != 0xffffffff`. + +Проверенный native tokenizer отличается от обычного строгого decimal parser: +`1e-2`, `1.`, `1e`, `1e+`, `1ee2` и `1e2.3` были приняты как один literal node +и дали payload `0x3c23d70a`, `0x3f800000`, `0x3f800000`, `0x3f800000`, +`0x3f800000` и `0x42c80000`. Для четырёх неполных exponent-форм значение +получено из распознаваемого числового prefix; это именно проверенные строки, +а не полное описание всех ошибок компилятора. + +Тип целевой ячейки сохраняется при записи: `float` (native tag `3`) принимает +исходные `f32` bits; `DWORD` (tag `5`) проходит через `__ftol` (RVA `0x1df70`) +с усечением к нулю и записывает младший DWORD. Поэтому literal `0.8` остаётся +`0x3f4ccccd` в `float`, но записывается как `0` в `DWORD`; `100.0` даёт raw +DWORD `100`. Неизвестные выражения не заменяются нулём, а записи первого `Init` +публикуются только после успешного прохода. + +Native x86 oracle на GOG `ai.dll` SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` загрузил +реальный shared `varset.var` (231 declaration) и реальные таблицы +`tut1_pl2.fml` (26 строк), `scr_pl_1.fml` (5 строк) и `scream.fml` (149 строк). +В первой кампании formula `0` записывает нули в DWORD `df0..df3` (indices +188–191); в `Single.01` formula `0` записывает нули в `dT8` и `dT9` (indices +186–187). Строки `scream` `0..8` задают следующие пары literal → raw cell value: + +| Formula | Target | Literal → raw cell value | +| ---: | --- | --- | +| 0 | `dMaxBuilder` (`DWORD`) | `0.4 → 0` | +| 1 | `fDifficulty` (`float`) | `0.8 → 0x3f4ccccd` | +| 2 | `dCurrentSender` (`DWORD`) | `0.5 → 0` | +| 3 | `dMaxTower` (`DWORD`) | `1.0 → 1` | +| 4 | `dMaxStorage` (`DWORD`) | `10 → 10` | +| 5 | `dMaxTransport` (`DWORD`) | `0.2 → 0` | +| 6 | `dMaxMine` (`DWORD`) | `100 → 100` | +| 7 | `dMaxPlant` (`DWORD`) | `400 → 400` | +| 8 | `d7` (`DWORD`) | `2 → 2` | + +Отдельный одноразовый x86 oracle на той же GOG `ai.dll` проверил ещё 13 +выражений: 9 строк контролируемой `controlled.fml`, строки 19 и 22 настоящей +`c1m3e.fml` и два последовательных вызова строки 0 настоящей `c1m4e.fml`. +Native harness записывал `f32`-результат evaluator-а, типизированную raw-запись +и getter после записи. В scratch-проверке те же входы прошли через публичные +`parse_varset`, `FormulaTable::parse` и `VarSet::resolve_init`; результат +evaluator-а и raw-слово Rust совпали с native во всех 13 случаях. Среди +проверенных границ: `2 - 3` записал в `DWORD` `0xffffffff`; чтение исходного +`DWORD 0xffffffff` дало `f32 0x4f800000`, а последующее `+ 0` записало в +`DWORD` ноль. Для настоящих строк `c1m3e` получены `fTemp = 0x40333333` и +`dTemp3 = 0`; два последовательных вызова строки 0 `c1m4e` дали +`fTemp = 0x4033335d`, затем `0x40333387`. + +Oracle принудительно устанавливал x87 control word `0x007f` на время каждого +проверяемого вызова: все 87 наблюдений показали входное и восстановленное +значение `0x027f`, активное и выходное — `0x007f`; canary-проверки прошли. +Менялись только varset-копии в памяти процесса. Эта выборка подтверждает +компиляцию, вычисление и типизированные записи ровно для перечисленных входов. +Она не запускала исходный поиск и обход события `Init` и не исполняла поздние +события или миссию; выборка не доказывает полную грамматику evaluator-а. + +Rust сохраняет всю таблицу строк, но вычисляет только формулу, на которую +ссылается поддержанная запись. Лицензированный runtime-тест подтверждает +полную загрузку кампании Mission.01 после этих присваиваний; у обычной +`Single.01` clan 1 после формул остаётся `Handler(43)` в instruction 10, который +не поддержан и не обходится. Preview оставляет значения этого клана без +частичных записей, а полный load возвращает ошибку транзакционно. В +`fparkan-assets` теперь отдельно доступны bounded TRF reader и pure cost core +для одного Unit DAT, но script/runtime пока не создают нужный research context, +не перечисляют AI candidates и не выполняют этот handler. Это не подтверждение +запуска самой игры или полной VM. + +В других строках тех же ресурсов встречаются `0.8 + fT` (`c1m3e.fml`, row 19), +`dBuildingProtectHits - dT2` (row 22) и `fTemp + 0.00001` (`c1m4e.fml`, row 0). +Декодированные `c1m3e.scr` и `c1m4e.scr` ссылаются на них из более поздних +событий `Fort_Captured`, `PBM_BUILDING_PROTECT_Start` и +`PBM_BUILDING_INF_CAPTURE_Start`, а не из их `Init`. Игнорируемый Rust-тест +подаёт эти сохранённые строки в изменённый синтетический `Init` и использует +настоящий общий `varset.var`; это проверка resolver-а, а не утверждение о +исполнении таких выражений в исходном `Init`. Отдельный native oracle и +сравнение с Rust выше проверяют эти строки при явно заданных исходных ячейках, +но не подтверждают их исполнение в оригинальном сценарии или в живой игре. + ### Первый вызов Behavior и сценарного `Init` В той же GOG-сборке `Behavior.dll` (SHA-256 @@ -610,10 +812,54 @@ tie-векторов подтверждает реакцию `FISTP`, но не Нижнеуровневый `VarSet::resolve_handler19` принимает уже преобразованные первые два слова и третье исходное слово, выдаёт три типизированные записи и отклоняет -отсутствующие, выходящие за диапазон или не-`DWORD` цели. Метод -`resolve_handler19_init` добавляет выбор события и остановку на неподдержанной -форме инструкции. Всё остальное содержимое `.scr` сохраняется reader-ом, но -остаётся вне этого исполнения. +отсутствующие, выходящие за диапазон или не-`DWORD` цели. Общий для первого +события метод `VarSet::resolve_init` добавляет этот `Handler(19)` и формулу из +одного числового или переменного атома либо двух таких атомов с одним бинарным +`+` или `-`. Остальные формы инструкции и содержимое `.scr` сохраняются +reader-ом, но остаются вне этого исполнения. + +### Начальная группировка подготовленных Control-дескрипторов + +В проверенной GOG `Behavior.dll` (SHA-256 +`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) +`FUN_1001be10` (RVA `0x1be10`) получает список через QI с кодом `0x202` от +`owner+0x5c`. Слоты `+0x50` и `+0x1c` этого интерфейса возвращают число строк и +код-различитель для строки; цикл обрабатывает порядковые номера строк +`0..count-1` по возрастанию. Метки `1`, `2` и `30` начинают группу, добавляют +кандидата и добавляют отдельный элемент к последней группе соответственно. +Запись с меткой `2` до первого заголовка попадает в отдельный вектор; метка `30` +без группы игнорируется, как и все остальные метки. + +Для источника списка проверена пара GOG DLL. В `Control.dll` SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` запрос с +кодом `0x202` отдаёт интерфейс `owner+8`; его методы-обёртки переходят к методам +чтения канонического QI `0x204`, receiver которого равен `owner+0x0c`. Метод +`Control.dll` RVA `0x8830` читает число из `[receiver+0x5b4]`, то есть +`[owner+0x5c0]`; таблица указателей находится в `[receiver+0x5b0]`, то есть +`[owner+0x5bc]`. Поле `[owner+0x5b4]` — отдельный указатель кэша, а не число +строк. Метод чтения `Control.dll` RVA `0x2c390` выбирает указатель `table[index]`, +разыменовывает указатель на дескриптор из записи `+0x48` и возвращает DWORD по +адресу этого дескриптора. Загрузчик `FUN_10021d50` (RVA `0x21d50`) сохраняет там +указатель текущего дескриптора, поэтому код-различитель — его текущая метка, а +не неизменная метка фабрики. + +В оригинальном Behavior внешняя строка имеет шаг `0x34`; её `+4` получает +порядковый номер заголовка. Строка-кандидат имеет шаг `0x58`, а её `+8` хранит +порядковый номер кандидата. Номера строк с меткой `30` добавляются в отдельный +вектор последней внешней строки. Rust публикует только эти начальные номера: +`MissionBehaviorControlGroup` и +`MissionBehaviorControlGrouping` группируют строки из уже подготовленной +Control-таблицы в её исходном порядке. `MissionBehaviorControlGroupingState` +сохраняет `original_id`, `ObjectHandle` и либо результат, либо исходную причину +недоступности Control-таблицы. Полная загрузка создаёт проекцию перед публикацией +`LoadedMissionState`; предварительный просмотр возвращает пустой список. Проверки +`behavior_control_grouping_uses_current_tags_and_table_ordinals` и игнорируемый +`full_load_builds_initial_behavior_control_groupings_for_autodemo` покрывают +искусственные граничные случаи и сохранённые номера строк AutoDemo соответственно. + +Это начальное разложение подготовленного списка, а не реконструкция полной +инициализации Behavior: отдельные свойства строки-кандидата, её числовые поля, +эффекты и обновление списка после изменений Control здесь не моделируются. ### Изменение provider-состояния Control @@ -912,6 +1158,71 @@ factory-класса. В Rust неизвестный текущий тег за CPU-вызов, ни локальные векторы не означают включения бюджетного шага в Control clock. +#### Глобальный Control getter: сырой селектор `0x0f` + +В `Control.dll` SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` +`FUN_1002b410` (RVA `0x2b410`) принимает сырой селектор в `EDX` и указатель +результата из `[ESP+4]`; общий выход использует `RET 4`. Сырой селектор `0x0f` +попадает в case RVA `0x2b8f5`. Владелец хранит ссылки на объекты, добавленные +фабриками `9` (`owner+0x5cc`) и `21` (`owner+0x5d0`). + +Фабрика `21` вызывает общий getter `FUN_10021d00` (RVA `0x21d00`): selectors +`0..5` читают шесть текущих `coefficient_bits` по `this+0x54`. Общий загрузчик +RVA `0x21d50` первоначально копирует descriptor words `[11..16]` в это поле. Фабрика `9` +использует getter `FUN_100255c0` (RVA `0x255c0`), который для selector `0` +умножает коэффициент `[this+0x54]` на множитель `[this+0x11c]`. Поэтому Rust +выбирает последнюю строку каждого `factory_tag` `9` и `21` в исходном порядке +таблицы; текущий `descriptor.tag` не меняет выбранную фабрику. Если любой строки нет, +результат отсутствует до проверки полей другой строки. + +Арифметика case `0x0f` сохраняет порядок и отдельное округление каждого шага: + +```text +sum = f32(D5) +for k = 4, 3, 2, 1, 0: + sum = f32(sum + Dk) +out = f32(C0 * sum) +``` + +`Dk` — текущий коэффициент `k` из `coefficient_bits` фабрики `21`; `C0` — +selector `0` фабрики `9`, то есть +`coefficient_bits[0] * class9_selector0_scale_bits`. Общие `multiplier_bits`, `node_provider_status` и +`native_word_50_bits` в этом обратном вызове не читаются. Rust проверяет, что +входы и промежуточные результаты конечны и являются обычными числами либо +точным нулём; переполнение, денормализованные числа и отсутствующие поля +остаются типизированными ошибками. Диапазон +проверяется через `f64`, а затем операция выполняется в `f32` с округлением +после каждого сложения. Это воспроизводит описанные записи `f32`, но не +моделирует полное управляющее состояние x87. Этот getter не реализует весь Control +metadata `0x36`, Handler `43` или каталог свойств. + +Лицензионный acceptance-test загружает `MISSIONS/Autodemo.00/data.tma` с явным +`MissionStartupInput { queue_time_ms: 100_000, research_creation_gate: false }` +и вызывает публичный `query_loaded_mission_control_resource(..., 15)`. Native +вызов исходного `Control.dll` того же SHA выполнен с `executionCW=0x007f` и +получил следующие биты на Rust-подготовленном снимке входных полей: + +| `original_id` | Результат selector `0x0f` | +| --- | --- | +| `0` | `0x46572800` | +| `1` | `0x4636d000` | +| `2` | `0x467b4001` | +| `3` | `0x468ca000` | +| `4` | `0x46ec4001` | +| `5` | `0x46ec4001` | + +Для `original_id` `6` и `7` таблица Rust не содержит обоих требуемых объектов +фабрик (`factory_tag` `9` и `21`), поэтому публичный запрос возвращает `None`. +Исходный обратный вызов для этих двух объектов отдельно не запускался; ветвь +неуспеха native getter-а проверена на отдельном векторе с отсутствующим указателем. + +Сравнение шести значений проверяет формулу getter-а на исходных игровых ресурсах +и снимке входных полей, подготовленном Rust. Оно не снимает состояние фабрик, +созданных оригиналом. Время и флаг `queue_time_ms` и +`research_creation_gate` задаёт тест, поэтому эти значения не подтверждают +начальное состояние запуска AutoDemo в оригинале. + #### Фабричный tag 26: изменяемая добавка В этой сборке `Control.dll` SHA-256 @@ -1099,18 +1410,36 @@ payload. Позже та же функция `World3D+0x7cb0` повторяет времени из `DATWorld+0x32a38` (`0x7e6e`); `World3D+0x7ea0` передаёт туда же время в `0x80a9`. Setter `World3D+0x5140` записывает входной DWORD в эту ячейку. Нулевой вызов связывает интерфейсы, а ненулевой `NumericTime` запускает отдельную -ветвь активации. Конструктор AniMesh задаёт `+0x158/+0x15c/+0x160` равными `1.0`. -Обработчик модели `0x75b0/0x6fc0` привязывает интерфейсы и матрицу, Effect RVA -`0x3d30` и Control RVA `0x1ce00` сохраняют host/service указатели, а Control -`0x1fe00` меняет только локальный collision-флаг. Эти ветви не вызывают AniMesh -scale setter `0x14770` и не пишут поля масштаба. При загрузке AniMesh получает -Control secondary owner с QI selector `0xa` и сохраняет возвращённый base owner +ветвь активации. Модель занимает отдельный объект в памяти: её конструктор +AniMesh RVA `0x68a0` задаёт `Model+0x158/+0x15c/+0x160 = 1.0`. +Поле `Agent+0x158` хранит указатель на Control. Проверенные ветви модели +`0x75b0/0x6fc0`, Effect RVA `0x3d30` и CollManager RVA `0x1ce00` сохраняют +матрицу и служебные указатели, оставляя масштаб модели неизменным. + +Фабрика CollObject возвращает вторичный интерфейс `object+4`. QI `0x28` +вычитает `4` и сохраняет в `Agent+0x170` основной объект с vtable `0x3c2fc`. +Его event slot `+0x08` ведёт в Control RVA `0x1f530`. При event `4/null` +обработчик кэширует семь host-интерфейсов; при наличии QI `0x203` вызывает +CollManager slot `+0x10` таблицы `0x3c178`. Этот метод, RVA `0x1c330`, +сохраняет только указатель `CollObject+0x20` в `CollManager+0x3c`. +Для свежего BULL Agent без внешнего host QI `6` при привязке даёт основной Agent +с vtable `0x201d4`. Ненулевой event `4` вызывает его slot `+0x24`, AniMesh +RVA `0x17d60`, который читает `[Agent+0x120]`; вызывающий код сохраняет +результат в `CollObject+0x24`. +Затем CollObject slot `+0x0c`, Control RVA `0x1fe00`, сбрасывает бит `0` +поля `CollObject+0x30`. Event `0x15` с первым DWORD payload `9` завершается +без вызовов и записей. Поле Control `owner+0x34` хранит указатель на CollObject; +его slot `+0x14`, RVA `0x1f670`, меняет флаги `CollObject+0x30`. +Перечисленные ветви не вызывают AniMesh setter `0x14770`. + +При загрузке AniMesh получает Control secondary owner с QI selector `0xa` +и сохраняет возвращённый base owner в `Agent+0x158`: `AniMesh.dll+0x2ff0` делает этот вызов в RVA `0x32ee..0x3300`, а `Control.dll+0x76f0` для selector `0xa` вычитает `0x14` из `ECX` и возвращает указатель. Диспетчер AniMesh RVA `0x1afb..0x1b0d` посылает через `Agent+0x158` событие `4` в slot `+0x08`. Его base-owner vtable — Control RVA `0x3d298`, и slot ведёт прямо в `0x314f0`; при нулевом payload исходный обработчик проходит -`0x7830 -> 0xf940`, поэтому FAC0 входит с constructor scales `[1, 1, 1]`. +`0x7830 -> 0xf940`, поэтому FAC0 получает Model scale `[1, 1, 1]`. Встречающиеся рядом переходники относятся к другим интерфейсам: Control owner `+4` с таблицей `0x3d254` проходит через `0x31ae0` (adjust `-4`), а owner `+8` с таблицей `0x3d1fc` — через `0x31b00` (adjust `-8`). Последний путь не является @@ -1166,6 +1495,77 @@ descriptor word `8`. Текущий tag `26` тем же общим reader-ом умножает на `M`. Это правило не меняет отдельные callbacks бюджета или запись factory-26 selector `0x500`. +Сырой selector `6` в `FUN_1002bb40` выбирает устройство по ordinal через +интерфейс `owner+0x0c`: таблица `this+0x5b0` соответствует `owner+0x5bc`. +RVA `0x2bb91` проверяет тег текущего descriptor на равенство `2`. При совпадении +RVA `0x2bb9a` загружает `device+0x174` как `f32`, сохраняет значение в выходной +буфер и возвращает `AL=1`/`RET 8`; другие текущие теги возвращают false. +Наличие поля `+0x174` подтверждено для экземпляра фабрики `2`. INTO сохраняет +исходный тип экземпляра, поэтому проверка тега descriptor отдельно не доказывает +наличие этого поля у другой фабрики. Rust selector `6` сначала проверяет +текущий descriptor tag: тег не `2` даёт `Ok(None)`; затем требуется известная +фабрика `2` и конечное нормальное число либо ноль любого знака в `+0x174`. В +`Control.dll` (SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) поле +получает ноль в конструкторе по RVA `0x294c0`; чтение, сравнение пары строк и +пересчёт находятся по RVA `0x2bb40`, `0x29940` и `0x296f0`. + +Typed record хранит две ASCII C-строки в words `[27..35)` и `[35..43)`. Строка +заканчивается на первом NUL: байты за ним игнорируются, а регистр при сравнении +не учитывается. Если новая, изменённая пара содержит пустую строку, native +recalc завершается до `CreateObject` и selector `35`, поэтому сохраняются +прежние биты. Пустая пара конструктора уже совпадает с текущей и вообще не +запускает recalc. Иначе append и первая активная запись `INTO` применяют +результат в исходном порядке. Для нераспознанной формы поле остаётся +недоступным; ошибка разбора распознанного, но повреждённого ресурса прерывает +новую загрузку до публикации. + +В общем случае native selector 35 не равен сумме сырых Max: +`Control.dll` getter `0x0dc40` (selector 3) читает Max по `descriptor+4`, где +указатель descriptor лежит в начале runtime provider row, затем умножает его +на масштабы владельца `+0x660` и `+0x548`. Функция `0x13620` складывает выходы +в порядке строк с округлением `f32`; при `row+0x24 != 0xffffffff` добавляется +индексированный вклад. Конструктор `0xb240` задаёт runtime `row+0x24` +служебное значение `0xffffffff`. Только в свежей форме поддержанного BULL оба +масштаба равны `1`, а все `row+0x24` равны +`0xffffffff`, поэтому формула сводится к NDP Max сумме. + +Пересчёт создаёт временный объект запросом `9` через `World3D.dll+0x7cb0`. +Загрузчик `Control.dll+0x32280` формирует для его корня входной контекст с +полем `+0xc8 = 0xffffffff`. Для разрешённых opcode `1` и `12`, а +также `4` только после FXID/CPT-проверки, подтверждённые callbacks сохраняют +входы суммы: `Control.dll` RVA `0x43d0` меняет только локальные слова; в +`AniMesh.dll` (`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`) +RVA `0x5500/0x93c0` меняют узлы и список модели, а `0x5320` не пишет её +масштабы. Это статическая граница свежего пути, не снимок живого native World +после всех creation callbacks. + +Проверка opcode `4` читает только выбранные строки и принимает FXID-типы +`2/3/4/5/7/8/9` с распознанными ASCII-ссылками и CPT offsets внутри компонента; +типы `1/6/10`, отсутствующие ресурсы и непроверенные offsets оставляют поле +недоступным. В `Effect.dll` (SHA-256 +`386a3e8db1ce23f61957825d633c7dae1ed40c25cab0cf10a534385fd7cee789`) проверены +инициализаторы типов `2/3/4/5/7/8/9` по RVA `0x12d10/0x103b0/0x108c0/0x28a0/ +0x1720/0x11230`; тип `9` разделяет путь типа `3`. + +`AssetManager::initial_control_effect_opcodes` собирает исходный порядок кодов +выбранной начальной группы до фильтра opcode `4`. `Some([])` означает, что +по структуре данных команд нет: группа отсутствует, пуста или её беззнаковый +индекс не меньше числа групп. Условия отдельных строк этот читатель не +вычисляет. `None` означает неподдержанный либо +неполный формат, а повреждённая структура возвращает ошибку. Такая разница не +позволяет принять неизвестный разбор за пустую сумму. + +Ресурсные callbacks в этой цепочке привязаны также к следующим образам. +Записи палитры и звуковые либо графические буферы имеют отдельное хранение; +проверенные начальные вызовы не меняют входы суммы NDP. + +| Модуль | SHA-256 | Существенные RVA | +|---|---|---| +| `World3D.dll` | `17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e` | `0x7cb0` — создание; `0x4b10/0x4cb0` — текстуры и lightmap | +| `Ngi32.dll` | `bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5` | `0x11f80` — чтение ресурса; `0x11410` — счётчик палитры; `0xd620/0xe900` — звуковые буферы | +| `Terrain.dll` | `af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` | `0x166a0/0x17ac0` — получение указателя на числовое поле World | + FCA0 передаёт в owner query порядковый номер `i`; query `0x2bb40` находит устройство `i` и вызывает его reader. Уже reader `FUN_10021d00` получает provider key из поля устройства `+0x04`. Provider getter `FUN_1000dc40` (RVA `0x0dc40`) @@ -1173,6 +1573,80 @@ key из поля устройства `+0x04`. Provider getter `FUN_1000dc40` ( строку `table + key*0x2c`; текущее type-1 значение находится по `+0x14` строки. Индекс устройства и ключ provider row — разные значения с разными задачами. +#### Индексный Control getter: сырой селектор `0x400` + +В той же сборке `Control.dll` с SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` +индексный query для `QueryInterface(0x204)` входит через слот `+0x18` vtable +`0x3d1b4`: `FUN_1002bb40` (RVA `0x2bb40`, `RET 8`, `ECX=owner+0x0c`, ordinal +устройства в `EDX`, selector и output pointer на стеке). При сыром selector +`0x400` функция попадает в ветвь RVA `0x2bc3c`. +Ветка получает provider pointer из `device+0x44`, отдельно читает provider key +из `device+0x04` и вызывает provider vtable slot `+0x0c` в режиме `1`, передавая +адрес ячейки selector как storage результата. После вызова значение переносится +в выходной `f32` caller-а; ветвь возвращает `AL=1` без проверки текущего +descriptor tag или factory tag. + +Поле `device+0x44` указывает на provider interface `owner+0x04` с vtable +`0x3d254`; slot `+0x0c` ведёт к `FUN_1000dc40` (RVA `0x0dc40`). Getter +использует key для строки общей таблицы, а не ordinal устройства. Для валидной +строки selector `1` читает её текущее type-1 поле `+0x14` напрямую: +`0x400` поэтому не пересчитывает долю из amount и maximum и не умножает её на +коэффициент устройства, `M` или descriptor word. В Rust тот же маршрут выглядит +так: + +```text +device = devices[device_index] +value = providers[device.key].current_type1_value +return Some(value) +``` + +Индекс устройства сначала выбирает запись, её key затем выбирает provider row; +этот indexed-вызов не является глобальным запросом `0x0f` и не является +metadata getter IID `0x16`. Проекция Rust принимает конечные нормальные значения +и точные `+0`/`-0`, сохраняя биты нулевого значения; остальные float-диапазоны и +недоступные table/key остаются типизированными ошибками. Native selector-cell +alias/fallback вне валидной записи provider не реализован: Rust не подставляет +для такого пути `0.0` или `None`. + +Шесть изолированных вызовов оригинала проверяют именно этот валидный +indexed-вызов. +В каждом ordinal устройства равен `2`, а ключ provider равен `1`, `3` или `4`; +все значения строк различаются, поэтому ключ не совпадает с ordinal и не +подменяется соседней строкой. + +| Вектор | Ordinal | Key | Raw output | AL | +| --- | ---: | ---: | ---: | ---: | +| Значение из корпуса `1.0` | 2 | 1 | `0x3f800000` | 1 | +| Доля `0.625` | 2 | 1 | `0x3f200000` | 1 | +| Отрицательное конечное `-2.5` | 2 | 3 | `0xc0200000` | 1 | +| `+0` | 2 | 1 | `0x00000000` | 1 | +| `-0` | 2 | 3 | `0x80000000` | 1 | +| Отличающаяся строка `0.0625` | 2 | 4 | `0x3d800000` | 1 | + +Вектор `0.625` специально задаёт native word `device+0x50 = 0`; он всё равно +возвращает текущее поле provider. Таблица устройства, provider и остальные +строки были синтетическими, ограждёнными входами; исполнены оригинальные +`FUN_1002bb40` и provider getter `FUN_1000dc40`, без constructors, ControlSystem +или mock callbacks. Все входные записи остались неизменными, проверены canary +области, а изменился только DWORD выхода. Для DLL с SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` входной +control word `0x027f` был временно заменён на `0x007f` и восстановлен после +вызова; у всех шести ветвей `AL=1`. Выходы совпали с прямыми значениями выбранных +synthetic rows. Это не снимок состояния оригинальной фабрики или миссии: полный +IID `0x204`, интеграция Behavior и gameplay-трасса не проверены. + +Независимый разбор лицензированного AutoDemo дал восемь таблиц и 100 пар +устройство/key, но все выбранные type-1 значения там равны `1.0`. Такой набор +проверяет загрузку таблицы, ключи и доступность запросов, но не позволяет +различить выбор строки по key и по ordinal. Игнорируемый Rust acceptance test +проверяет все 100 запросов на этом корпусе и ожидает `0x3f800000`. В тесте явно +заданы `MissionStartupInput { queue_time_ms: 100_000, +research_creation_gate: false }`; эти значения являются входами Rust-проверки, +а не доказательством штатного gate оригинала. Native синтетические строки дают +отдельное доказательство выбора key. Оба результата имеют ограниченную область +и не являются сравнением полного поведения игры. + Конструктор owner `FUN_10006bf0` (RVA `0x6bf0`) задаёт scalar `+0x1a8 = 1.0` и вызывает `FUN_10014260`, который записывает `1.0` в `+0x354/+0x358`. Проверенные ветви канонического event `4/null` и FAC0 не меняют эти три поля diff --git a/docs/appendices/ui-shell.md b/docs/appendices/ui-shell.md index d236e14..58d4083 100644 --- a/docs/appendices/ui-shell.md +++ b/docs/appendices/ui-shell.md @@ -26,10 +26,103 @@ Win32 messages `sprites.lib` и `Palettes.lib`. Конфигурации задают controls/resources/cursor, overlays и HQ; `TextRes.cfg` связывает символические ключи с текстом. -`gamefont.rlb` содержит две LZSS-записи (`0x040`), `sprites.lib` — 24 записи -raw Deflate (`0x100`). `sprites.lib::INTERF8.TEX` использует известный -`deflate_eof_plus_one` quirk. UI обязан пользоваться общим RsLi reader, а не -отдельной похожей распаковкой. +В demo `gamefont.rlb` содержит две LZSS-записи (`0x040`); `sprites.lib` +содержит 24 записи raw Deflate (`0x100`) и известный `deflate_eof_plus_one` +quirk для `INTERF8.TEX`. В полной GOG-сборке меню отдельно связывает +`ui/menu_resources.cfg` с `ui/font.lib`, архивом NRes. Поэтому `gamefont.rlb` +не задаёт единственный путь шрифта. Оба контейнера уже читаются общими +[RsLi](../reference/rsli.md)- и [NRes](../reference/nres.md)-readers проекта; +будущая UI-подсистема должна пользоваться ими, а не добавлять свою распаковку. + +В `menu_resources.cfg` ресурс `fonts` указывает `ui\\font.lib` с `libtype=multi` +и `type=2`; это поля CFG, а не FourCC записей NRes. У девяти записей +`font.lib` `type_id` равен `0x544E4F46` (`FONT`). Значение подстановки в +`font-substitute` — строковое имя: этот CFG alias (`MENU_FONT` или `GAME_FONT`) +связывает роль шрифта с его именем, но сам не является именем записи архива. +Числовые ссылки находятся отдельно, в объекте `fonts`. В проверенной паре CFG и +архива эти ссылки совпадают с исходными порядковыми номерами записей NRes +(`EntryId`), а не с `sort_index`: + +| Размер CFG | Строковый alias `font-substitute` | Ссылка `fonts` | `EntryId` и запись NRes | +| --- | --- | --- | --- | +| `640x480` | `MENU_FONT` | `8` | `8`, `mf_640.tft` | +| `640x480` | `GAME_FONT` | `6` | `6`, `gf_640.tft` | +| `800x600` | `MENU_FONT` | `5` | `5`, `mf_800.tft` | +| `800x600` | `GAME_FONT` | `3` | `3`, `gf_800.tft` | +| `1024x768` | `MENU_FONT` | `2` | `2`, `mf_1024.tft` | +| `1024x768` | `GAME_FONT` | `0` | `0`, `gf_1024.tft` | + +Пример из этой CFG: `font-substitute` связывает `MENU_FONT` со строкой +`MENU_FONT_1024x768`, а объект `fonts` связывает уже это имя с числом `2`. +Такое сопоставление относится только к этой конфигурации и архиву, а не является +универсальным правилом native selector. Строка `1024x768` не доказывает выбор +этого варианта при фактическом экране `1280x1024`. + +Проверены оригинальные `menu_resources.cfg` (SHA-256 +`508C3783EAF112023E46BF4617424FBB57C393BCEF328CE1B925549B0069EF97`) и +`font.lib` (440392 bytes, SHA-256 +`E4AF5D1F0BF0464574DE654DE378766090681F610705CE53539DF9041E045D1C`). +Шесть сопоставленных payload начинаются с `Tfnt`; это подтверждает сигнатуру +данных, но не значение последующих полей. + +Связка подтверждена и в оригинальном коде. В `iron3d.dll` SHA-256 +`D1DC4EA8535E2069B0A05C9E8BE2724DD438BA44CCA7A83007D8B2E50F9E35FA` shell +читает `shell_ctrls.cfg` в RVA `0x7410`, а инициализация в `0x78c0` читает и +регистрирует `menu_resources.cfg` в слоте `0` ResourceManager. `services.dll` SHA-256 +`D3619DEEB4D598B16099E0263E15F9C3213F0FAA7208DC093328CFBA93FDC0EA`, RVA +`0x92a0`, разбирает `font-substitute`, размерные значения и запись ресурса +`type=2`. Затем `iron3d.dll` запрашивает `MENU_FONT` в RVA `0x1eb20`, ищет ресурс +в `0x1ed06` и разыменовывает результат в `0x1ed3e`; прямая x86-трасса на +последнем участке подтвердила ненулевой объект. Это подтверждает получение +реального font-объекта, но не конкретной NRes-записи при `1280x1024`, её +отрисовку или паритет шрифтов. + +## Фиксированный reader Tfnt + +В подтверждённом пути `World3D.dll` (SHA-256 +`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`) +передаёт ordinal NRes-reader-у в RVA `0x1361f`, а затем передаёт полученный +указатель renderer-у в RVA `0x13624`; бит `0x400000` установлен у вызова в RVA +`0x1360d`. В `Ngi32.dll` (SHA-256 +`bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5`) vtable +NRes у RVA `0x318bc` ведёт к `FUN_10011f80` (RVA `0x11f80`), которая возвращает +`mapped_base + data_offset`. Renderer vtable у RVA `0x315e0` направляет slot +`+0x58` в `FUN_10007dd0` (RVA `0x7dd0`), затем ресурс проходит через +`FUN_10010c10` (RVA `0x10c10`) и хранит pointer в `FUN_1000f3d0` (RVA `0x0f3d0`). +Loader font-объекта `FUN_1000fb30` (RVA `0x0fb30`) при флаге `0x400000` +использует этот адрес как начало Tfnt payload; отдельная RsLi-обёртка в этом +пути не читается. + +| Смещение | Что читает `fparkan-font` | +| --- | --- | +| `0x00..0x03` | Сигнатура `Tfnt`; reader проверяет её на границе файлового API. В наблюдавшемся native loader проверки сигнатуры не было. | +| `0x04..0x07` | `i32`, нижняя граница ширины в native fixed-width path. | +| `0x08..0x0b` | `i32`, вертикальный размер для native clipping/drawing. | +| `0x0c..0x0f` | Сырые биты `u32`, скопированные в font object и участвующие в float-сложении при drawing; имя и смысл поля неизвестны. | +| `0x10..0x13` | `i32`, прибавляемый один раз на входной байт при native text measurement. | +| `0x14..0x1013` | 256 записей по 16 байт: три сырых DWORD и signed `i32` advance по смещению `+0x0c`. | +| `0x1014..конец` | Обычный Texm-документ, передаваемый общему `fparkan-texm::decode_texm`. | + +Первые три слова glyph record имеют разные native consumers, но их общая +семантика не восстановлена: одно слово загружается как float, другое попадает +в сформированные vertex data, а третье не прояснилось в исследованных ветвях. +Поэтому reader сохраняет их как `word0..word2`, а поле заголовка по смещению +`0x0c` — как неинтерпретированные float-биты. Он не выбирает размер текстуры по разрешению +дисплея и не задаёт диапазон размеров: в проверенных ресурсах встречаются +`128×128` и `256×256`, а форматы, mip-цепочку и палитру проверяет общий Texm +reader. + +```rust +let font = decode_tfnt(Arc::<[u8]>::from(raw_font_payload))?; +let glyph = font.glyphs[usize::from(input_byte)]; +let texture = font.texm; +``` + +Игнорируемый corpus-тест разбирает через общий NRes reader девять записей +`ui/font.lib` и `ARIALTEX.TFT` из `sys.lib`; он сверяет известные размеры, +Indexed8, одну mip-ступень и выборочные поля `gf_640.tft`. Это проверка байтов +лицензированной GOG-сборки и parser contract, а не запуск shell или проверка +отрисовки. Ключ ресурса, legacy path, локализованный текст и исходные bytes — четыре разные идентичности. Archive keys остаются ASCII-casefold; `TextRes.cfg`, @@ -49,10 +142,11 @@ design resolution. Отдельно хранятся legacy-layout coordinates, viewport, scale/letterbox policy, mouse-to-layout transform, clipping и z-order. Cursor, sprite и hit-test обязаны применять одно преобразование. -Font contract включает glyph image, advance, bearing/offset, line height и -fallback glyph. Binary glyph metrics `gamefont.rlb` пока не восстановлены: -payload читается lossless через RsLi, а семантика устанавливается по consumer -trace. +Font contract включает glyph image, bearing/offset, line height и fallback +glyph. `fparkan-font` читает фиксированную таблицу Tfnt и отдаёт signed advance +и embedded Texm; он не выполняет text measurement/layout или glyph drawing. +`gamefont.rlb` по-прежнему читается общим RsLi reader, но его native consumer не +установлен. Нормализованное событие проходит modal widget, затем shell command map; лишь разрешённая gameplay-команда превращается в World3D manual event. Held state и diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md index 3267a2f..45d7725 100644 --- a/docs/reference/environment-effects.md +++ b/docs/reference/environment-effects.md @@ -236,11 +236,19 @@ packed: [key.colors[10], key.colors[14]], `AtmosphereFrame` пока не несёт этот бит обновления, поэтому sampler выдаёт интерполированный цвет при каждом запросе. -## Экранная геометрия солнца и луны +## Отрисовка солнца, луны и бликов -`CSun::Render` сначала переводит направление небесного тела в пиксели текущего -viewport, затем собирает quad с native XYZRHW-позицией. При положительной высоте -viewport Vulkan пиксели переводятся в clip-space так: +`CSun::Render` получает текущий viewport, проецирует направление солнца или +луны и собирает quad уже в его пикселях. `Terrain.dll` VA `0x1004AA40` передаёт +эту геометрию через shade callback; native FVF `0x1c4` задаёт +`XYZRHW | DIFFUSE | SPECULAR | TEX1`. Конвертер `0x10020700` сохраняет входные +`x`, `y` и `z`, а в `RHW` записывает `1.0`; исходный `z` равен `0.999`. Callback +включает depth test и выключает запись depth, поэтому более близкая поверхность +может закрыть спрайт, а сам спрайт не меняет глубину для последующих слоёв. + +В Vulkan ряды 3 и 4 сохраняют этот contract отдельным bit 8 material mode. +CPU каждый кадр преобразует viewport-пиксели к clip space текущего размера +swapchain: ```rust position = [ @@ -250,49 +258,102 @@ position = [ ]; ``` -Vertex shader пропускает `clip_from_world` для строк солнца и луны. CPU каждый -кадр пересчитывает центр и размер quad по текущему viewport; старые пиксельные -координаты не переносятся на новый размер. World-space billboards бликов -остаются отдельным путём. Материал экранного quad не освещается, vertex fog -отключён, а sky far-depth bit к нему не применяется. - -Native `z = 0.999` не означает, что спрайт гарантированно лежит за любой -поверхностью. Vulkan использует `LESS_OR_EQUAL` и не записывает depth для -прозрачного ряда. При near `0.5`, far `700` мировая поверхность на расстоянии -`320` получает depth -`700 / (700 - 0.5) * (1 - 0.5 / 320) ≈ 0.999151`; поэтому экранный `0.999` -чуть ближе этой поверхности и проходит depth test перед ней. Это фиксированное -native значение сохраняется для совместимости, но само по себе не обещает -окклюзию солнца всей геометрией местности. - -## Геометрия визуальных FX-команд - -`fparkan-fx::fx_visual` формирует кадры для двух подтверждённых вариантов -`f_gener_ball`. Op4 принимает mode 0 с положительным знаменателем в `body+0xC4` -и выдаёт camera-facing quad; расстояние камеры считается через native -component-wise projection родительской матрицы. Op9 принимает mode 2: -селектор `body+0xC4` со значением 0, 1 или 2 отображается в native flag base -3, 5 или 6 и выбирает одну из трёх сеток; категория render flags выбирает её -детализацию. `body+0xC8` выбирает UV path: -нулевое значение проецирует позицию вершины на XY, ненулевое использует -фиксированные координаты quad и cap. Кадр сохраняет MAT0 resource, фазу, -opacity, вершины и triangle indices для renderer. - -Evaluator получает готовые command body, phase, camera point и внешнюю -матрицу. Выбор Control row, manager gates, owner attachment, header jitter и -global RNG остаются задачей владельца. `fparkan-game` пока использует для Op9 -категорию 0 с наибольшей детализацией; выбор категории по projected size ещё -не реализован. - -Для Op3 поддерживаются body-типы 0, 1 и 2. Поля отсчитываются от -`FxCommand.raw_body`, то есть после 4-байтового command word. Типы 0 и 1 -используют общие position/scale/phase/opacity tracks и camera-facing основу; -тип 1 и тип 2 также интерполируют ось, а тип 2 строит basis через native -D110-порядок. Все три evaluator-а принимают уже собранную матрицу владельца и -не выбирают Control row или attachment. Текущий путь для типов 0 и 1 -предполагает native render context 0: `Effect.dll` проверяет общий gate -`context + 0x60 < 8`, а API этих evaluator-ов не выбирает другой context. -Evaluator типа 2 принимает индекс context явно и учитывает этот gate в `active`. +Положительная высота Vulkan viewport отображает эти координаты в тот же +top-down экран. Vertex shader пропускает мировую матрицу только для этого +режима: + +```glsl +vec4 clip_position = screen_tl + ? vec4(in_position, 1.0) + : frame.clip_from_world * vec4(in_position, 1.0); +``` + +Материал остаётся unlit, fog factor равен нулю. Для мировой геометрии +оригинал задаёт viewport `MinZ = 0.1`, `MaxZ = 0.99`: после отсечения +проекционная глубина отображается в этот диапазон: + +```text +world_depth = 0.1 + (0.99 - 0.1) * (clip_z / clip_w) +``` + +Даже дальняя гора получает глубину не больше `0.99` и закрывает экранный +диск с `z = 0.999`. Если оставить мировой диапазон `0–1`, при near `0.5` +и far `700` поверхность дальше примерно `292` единиц окажется позади диска: +солнце начнёт просвечивать сквозь неё при движении камеры. Одного переноса +нативного `z` в экранные вершины недостаточно. + +В Vulkan диапазон задаётся динамическим viewport отдельно для мировых +пакетов; экранные TL и фоновые sky-пакеты сохраняют диапазон `0–1`. +Матрица проекции и плоскости отсечения при этом не меняются. Константы +мирового профиля записывает GOG `Terrain.dll` RVA `0x27422–0x27452` из таблицы +`0x65088/0x6508C/0x650A0/0x650A4` (near, far, MinZ, MaxZ). GOG `Ngi32.dll` +RVA `0x7170`, участок `0x71E7–0x723C`, копирует MinZ/MaxZ в renderer +`+0x30/+0x34` и передаёт их `IDirect3DDevice7::SetViewport`; сборки указаны в +[таблице бинарников](original-binaries.md). Нативные значения этих двух полей +имеют биты `0x3DCCCCCD` и `0x3F7D70A4`. + +Sky-background bit 4 продолжает помещать слои на дальнюю плоскость. Отдельные +flare rows 5 и 6 пока используют world-space billboard builder: для них не +подтверждён этот же native TL путь. + +## Эффекты Control + +Погодное расписание `sky.ske` — только один источник эффектов. У объектов +миссии таблица Control (`.ctl`) связывает строки с FXID-командами. Текущий +preview выбирает начальную группу Control и исполняет только строки без +predicate, chain и state gate. Условия игровых событий и переходы между +группами принадлежат полному игровому Control runtime и здесь не моделируются. + +Type 4 row задаёт локальную basis эффекта через три точки CTL point list. Для +валидной привязки первая точка также задаёт owner node. Путь MSH выбирает позу +этого узла из текущей pose hierarchy; для внешнего mounted-компонента к ней +добавляется поза socket. После этого применяются object transform, header +scale и локальная матрица FX. Подробнее о порядке матриц см. +[описание MSH](msh.md#control-fx-и-owner-transform). + +Renderer preview поддерживает FXID-команды Op3 modes 0–2, Op4, Op7, Op8 и Op9. +Op3 mode0 строит camera-facing поверхность из анимируемых локальных позиции и +масштаба; Op7/Op8 обновляют отдельные элементы particle pool; Op9 создаёт +индексированную полусферическую сетку. Op1 вычисляется отдельно как список +point lights: selector 1 и записи без `flags & 0x80000000` входят в общий +diffuse light set. Записи с этим битом сохраняют native flags для специальных +путей и projected shadows, но не добавляются в общий diffuse список. + +Body discriminator Op3 читается из первого DWORD `FxCommand.raw_body`; offsets +в evaluator-е относительны к данным после четырёхбайтового command word. Mode 1 +добавляет анимируемую ось к camera-facing basis, mode 2 использует native +D110 basis. Для modes 0 и 1 preview предполагает render context 0, который +проходит общий gate `Effect.dll` `0x106C0`; их API не выбирает другой context. +Evaluator mode 2 принимает индекс context и учитывает gate при расчёте `active`. + +У Op3 mode 1 ось задаёт длину прямоугольника, а не его нормаль. Для +локального вектора к камере `v` и анимируемой оси `a` столбцы базиса имеют +порядок `[a, u, w]`, где `u = normalize(v × a)`, `w = a × u`. Плоскость +прямоугольника — XY, поэтому первая сторона идёт вдоль авторской оси, а +вторая поворачивается к камере. Это сохраняет соединение между точками +локатора при движении камеры. Если поставить ось в третий столбец, +прямоугольник займёт поперечную плоскость и связь превратится в полоску или +небольшой диск. + +Порядок подтверждён аргументами конструктора матрицы в GOG `Effect.dll` +(сборка из [таблицы бинарников](original-binaries.md), SHA-256 +`386a3e8db1ce23f61957825d633c7dae1ed40c25cab0cf10a534385fd7cee789`): +RVA `0x93D0`, ветка mode 1 с `0x9739`, сборка столбцов `0x97F6–0x983C`. +Например, `a = (1, 0, 0)` и `v = (2, 1, 0)` дают столбцы +`(1, 0, 0)`, `(0, 0, -1)`, `(0, 1, 0)`. При нативном вызове длины последних +двух столбцов могут немного отличаться от единицы из-за приближённой +нормализации выбранного `g_FastProc`; порядок осей от этого не меняется. + +У сооружения `s_tree_58` в миссии «Конструктор» FXID `tree_ray_58` задаёт два +слоя жёлтого луча: команды Op3 mode 1 с ресурсами `defl_y_add` и `defl_y1_add`. +Их геометрию сверили с CPU-вызовом GOG `Effect.dll` `0x93D0`. На семи временных +фазах и трёх направлениях камеры для каждой команды (42 выборки) вершины +совпали с Rust evaluator с максимальной покомпонентной разницей `0.0001373291`. +Оракул получает уже выбранные Rust позицию, ось и масштаб, а world-camera +переводится в `cameraLocal` тем же component-wise projection, что и native +`FastProc + 0x68`. Поэтому это подтверждает только геометрию Type 1 helper-а на +этих входах: обновление и расписание эффекта, UV/material и пиксельное +совпадение здесь не проверялись. ## Частицы Op7 @@ -314,27 +375,66 @@ Evaluator типа 2 принимает индекс context явно и учиÑ семена отдельных экземпляров лишь детерминированно заменяют process-global состояние и не воспроизводят общий порядок вызовов. +`f_gener_ball` evaluator покрывает Op4 mode 0 с положительным знаменателем в +`body+0xC4` и Op9 mode 2. Для Op9 selectors `body+0xC4` со значениями 0, 1 и 2 +отображаются в native flag bases 3, 5 и 6. Нулевой `body+0xC8` проецирует UV на +XY, ненулевой выбирает статические quad/cap координаты. Op4 вычисляет локальное +расстояние камеры тем же component-wise projection, что использует native path. + +FX-команды используют общий preview clock и ограниченный набор time modes: +mode 0 оставляет фазу равной нулю, mode 1 вычисляет `elapsed / duration` без +зажатия, mode 2 повторяет цикл. Header flags `0x200` и `0x20` преобразуют эту +фазу; command path `0x8120` зажимает результат только после преобразований. +Effect renderer проверяет header bit `0x800` только при +render selector 0; native AniMesh component path был захвачен с selector 1, +поэтому preview использует selector 1 и не подавляет такие visual commands. +Этот capture также показал `0xD00` FX entries в 53-entry manager. Динамическое +состояние manager `A8`, связанное с header gate и фактической FX phase, пока не +моделируется. Header bit `0x10` относится к activation lifecycle и сам по себе +не отключает draw. +Header jitter и random shift хранятся один раз на Control row и совместно +применяются к его sibling-командам; отдельный детерминированный поток каждой +строки сохраняет эту связь, но не повторяет порядок глобального native RNG. +Неразобранные группы, event predicates и неразрешённые owner attachments +пропускаются. + +Для visual FX material берётся из resource reference команды. MAT0 выбирает +фазу по normalized FX phase, независимо от wall-clock анимации world materials. +Command opacity передаётся vertex alpha, а FX MAT0 alpha не умножает её второй +раз; texture alpha продолжает участвовать в shader. Текущая preview-ветка +использует SourceAlpha и read-only depth state и допускает только MAT0-фазы, +проходящие локальный predicate additive-RGB approximation. Семантика native +`flag40` и directional-light ветки остаётся открытой. + +Generated Op9 mesh разделяется на отдельный draw range для каждого native +quad и cap. Каждый range получает ключ по камере до первой индексированной +вершины, в порядке убывания расстояния. Равные ключи используют midpoint +insertion из native queue, поэтому их порядок не является стабильным по +первоначальному времени поступления. Preview использует category 0 как +highest-detail mesh; projected-size LOD и полное визуальное совпадение размера +и формы пока не реализованы. + ## Частицы Op8 `fparkan-fx::smoke` разбирает body Op8, хранит кольцевой пул команды и выдаёт quad-ы с ориентацией к камере для активных слотов. Нативная нормализованная -фаза окна ограничивается диапазоном 0–1 и задаёт интервал проверки следующего события; -сравнение строгое (`schedule_base + interval < update_clock`). Все события -catch-up в одном обновлении проверяются по тому же нормализованному интервалу; -после события нативный код продвигает базу расписания интервалом, вычисленным -по сырой фазе эффекта, а возраст слота считает по нормализованному интервалу. -Когда фаза равна единственной границе окна, деление `0/0` даёт NaN, который -нативный unordered-путь сравнения ограничивает нулём. +фаза окна ограничивается диапазоном 0–1 и задаёт интервал проверки следующего +события; сравнение строгое (`schedule_base + interval < update_clock`). Все +события catch-up в одном обновлении проверяются по тому же нормализованному +интервалу; после события нативный код продвигает базу расписания интервалом, +вычисленным по сырой фазе эффекта, а возраст слота считает по нормализованному +интервалу. Когда фаза равна единственной границе окна, деление `0/0` даёт NaN, +который нативный unordered-путь сравнения ограничивает нулём. Нулевая база расписания служит признаком начального состояния: обновление сохраняет текущую опору эмиттера и базу времени, не создавая частицу. Если первое время равно нулю, следующее обновление повторно захватывает опору. При событии скорость опоры считается от сохранённой позиции до текущей за время от базы расписания; слот смещает сохранённую опору на `скорость * due_interval`. -Обновление без событий не переносит сохранённую опору к текущему владельцу. -Откат часов корректирует только базу расписания до текущего времени: слоты, -опора и три потока случайных -чисел не сбрасываются. +При нулевой скорости оригинал подставляет ось X владельца и для направления, +и для этого смещения. Обновление без событий не переносит сохранённую опору к +текущему владельцу. Откат часов корректирует только базу расписания до текущего +времени: слоты, опора и три потока случайных чисел не сбрасываются. Evaluator принимает отдельные начальные состояния для эмиттера, знаковых отклонений и материала. Переход каждого потока совпадает с общим native LFSR, diff --git a/docs/reference/msh.md b/docs/reference/msh.md index a28514d..130a203 100644 --- a/docs/reference/msh.md +++ b/docs/reference/msh.md @@ -18,6 +18,7 @@ type 9 service stream type 10 strings and node names type 13 Batch20 records type 15 auxiliary stream +type 16 optional vertex-colour data type 17 auxiliary data type 18 packed UV1 (optional secondary coordinates) type 19 animation frame map @@ -25,6 +26,9 @@ type 20 rare auxiliary table ``` Reader ищет entries по type, но сохраняет исходный порядок для roundtrip. +Поле `ModelAsset.type16_present` отмечает только наличие entry типа 16. Данные +payload сохраняются, но этот путь рендера пока не преобразует их в цвета +вершин. ## Node and slot selection @@ -91,6 +95,51 @@ translation суммируется, а rotations умножаются; послÐ запускает игровой Control/AI и не обещает полного совпадения с runtime или всеми x87 rounding modes. +## Control clock и pose компонентов + +Статический Vulkan preview начинает с позы, вычисленной из сохранённых `.ctl` +bindings. Пока не задан `--static-animation-frame`, при каждом новом сэмпле +сцены приложение сэмплирует поддержанные tag 21 записи для каждого экземпляра +компонента и заново строит его MSH node hierarchy; time и transition используют +общий runtime clock. Динамический sampler принимает tag 21 mode `0`, +controller flags `5` (raw value `0xFFFFFFFF` трактуется как `5`), +разрешённый gate provider и static row mapping: оба +variable index равны `0xFFFFFFFF`, previous-row flag не установлен, transition +rate конечен и положителен. + +Fallback к статической CTLD-позе всего экземпляра компонента срабатывает только +при следующих ошибках sampler: `UnsupportedTag21Mode`, +`UnsupportedVariableMapping`, `MissingGateProvider`, +`GateProviderKeyOutOfRange`, `MissingGateProviderValue` и +`GateProviderCountMismatch`. Он заменяет весь динамический результат экземпляра, +а не одну запись. Остальные sampling errors завершают обновление сцены; например, +`UnsupportedPreviousRowMapping` и `InvalidTransitionRate` не включают fallback. + +`--control-clock-ms <u32>` воспроизводит общий MSH/FX clock линейно: первый из +`--frames` успешно представленных кадров получает `0`, последний — заданное +время. Replay clock привязан к числу представленных кадров: повторная подготовка +того же кадра, включая swapchain recreation, использует прежний sample. +`--static-animation-frame <u16>` отключает динамическую MSH выборку и задаёт один +frame всем компонентам. Этот override не включает AI и не превращает preview +в полный игровой Control runtime. + +## Control FX и owner transform + +Для выбранной Control effect group runtime берёт строки без predicate, chain и +state gate. Type 4 row offsets выбирают три точки из CTL point list; позиции +точек задают локальное начало, а их направления — локальную basis. К basis +применяются FX header scale и локальные random shifts. Затем матрица проходит +через mission rotation/translation и object scale, optional mounted-component +pose, owner-node pose и локальную матрицу эффекта. Поэтому FX mounted-компонента +следует её текущему MSH owner pose, а не остаётся привязанным к mission root. + +Когда point record задаёт допустимый owner node, его pose разрешается из MSH +hierarchy. Для mounted external components node pose вычисляется относительно +component root и применяется вместе с уже рассчитанным mount pose. Internal +`INTO` child controller/equipment links пока не интерпретируются как MSH socket; +если owner pose нельзя разрешить, его FX пропускается. Нативное сочетание child +component scale, equipment attachment и CPT basis остаётся неустановленным. + ## Vertex streams Основные vertex streams имеют фиксированный source index. У type 3 `attr1` @@ -149,11 +198,114 @@ struct Batch20 { ``` Byte `material_index` выбирает строку WEAR; high word at `+0x02` сохраняется -отдельно для native overrides. Loader проверяет, что +отдельно для native overrides. Слово `local_batch_index` остаётся raw-полем +`+0x06`: в пути CBuilding helper retry оно вместе с базой модели задаёт +связанный корень Node38, а у отдельной `ModelAsset` база равна нулю. Вне этого +пути слово сохраняется без вывода о его значении. Loader проверяет, что `index_start + index_count` остаётся внутри type 6, а каждый использованный индекс через `base_vertex` попадает в position stream. `vertex_count` и `local_batch_index` доступны renderer для исходного draw contract. +## Type 7 и сегментный запрос + +Type 7 необязателен для обычного MSH preview. Если stream есть, каждые 16 bytes +описывают одну грань: `tri_flags` находится по `+0`, четыре неинтерпретируемых +слова — по `+2`, `+4`, `+6` и `+14`, а signed `normal[3]` — по `+8`, `+0xA` и +`+0xC`. `ModelAsset.triangle_descriptors` сохраняет декодированную таблицу как +`Option`; отсутствие Type 7 не делает статическую модель недействительной. + +`query_slot_segment` использует Node38 header mask, Batch20 leading DWORD +`batch_flags | (material_index_hi << 16)` и `tri_flags`. Для каждого поля +include mask требует все установленные биты, exclude mask пропускает запись при +любом совпавшем бите. Descriptor cursor начинается с `Slot68.tri_start` и +сдвигается на `Batch20.index_count / 3` после каждой записи, даже если batch +или грань отфильтрованы. Индексы грани читаются из type 6 с учётом +`base_vertex`. + +Правило восстановлено для GOG-бинарей `AniMesh.dll` +(`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`) и +`Ngi32.dll` (`bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5`). +Callback `AniMesh.dll+0x11090` переводит signed type-7 normal множителем +`1 / 32767`; функция `AniMesh.dll+0x11EB0` записывает этот scale в глобальное +поле. Callback вызывает FastProc slot `+0xB4`: в этой сборке он указывает на +`Ngi32.dll+0x1D560`. После обычной загрузки DLL порог `Ngi32.dll+0x37224` +равен нулю, поэтому plane test принимает только переход от положительного +расстояния к неположительному (`dot(normal, end - start) < 0`, расстояние в +начале `> 0`, в конце `<= 0`). `Batch20.batch_flags & 0x0002` разрешает повторный +plane test в обратном направлении, только если прямой plane test не пересёк +плоскость. Пересечение проверяется export ordinal 202 +(`Ngi32.dll+0x1E60`, `mrnPointInPoly`): проекция выбирает ось с наибольшим по +модулю normal component, а точка проходит грань, пока ни одно edge expression +не меньше нуля. Равенство нулю принимается, поэтому попадание на ребро или +вершину считается попаданием. Поздний кандидат с тем же squared distance +заменяет предыдущий; расстояние считается от исходного первого endpoint. + +Native helper получает точку пересечения через `RCPSS` и один шаг уточнения; +Rust использует обычное деление `f32`, поэтому последние биты координат могут +отличаться. Игнорируемый тест с ресурсами проверяет исходную стену, дверной +проём и обратное направление на `fortif.rlb/fr_l_plant.msh`. + +### Мировой отрезок по выбранным узлам + +Локальный запрос выше проверяет один узел и slot модели. Runtime-функция +`query_building_segment_world` принимает мировой отрезок и исходный список +выбранных узлов со slot-ами и их преобразованиями `world_from_node`. Первый +проход сохраняет порядок этого списка, переводит концы отрезка обратной +матрицей и выбирает ближайшее попадание в мире. + +```text +segment_node = inverse(world_from_node) * segment_world +hit_world = world_from_node * hit_node +distance² = dot(hit_world - original_start, hit_world - original_start) +``` + +`original_start` остаётся началом первоначального мирового отрезка, даже когда +запрос перезапускается. Если ближайший batch имеет bit `0x0008`, его ключ +`local_batch_index` выбирает новый корень Node38, а отрезок начинается в +мировой точке helper-попадания и идёт к исходному концу. Runtime обходит +метаданные этого поддерева даже у узлов без переданной геометрии; сами slot- +запросы выполняются только для узлов, присутствующих во входном списке. Обход +проверяет сначала допустимых потомков, затем сам узел. Header bit `0x0010` +прекращает обход его потомков; bit `0x0004` у дочернего узла исключает всю эту ветвь, но bit +`0x0004` у текущего корня не мешает запросу его собственной геометрии. Слово +родителя `0xFFFF` не связывает узел с дочерним корнем, а helper-ключ `0xFFFF` +считается ошибкой данных. + +В каждом проходе batch `0x0200` и type-7 triangle flags `0x0024` исключаются. +Ближайшее попадание и разрешение равных расстояний сохраняют правила локального +запроса; расстояние измеряется от первоначального начала. Счётчик ограничивает +все пересечения: при посещении 200 запрос завершается ошибкой до проверки bit +`0x0008`. + +Эта схема сверена с GOG `Terrain.dll` SHA-256 +`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` (RVA +`0x35430`, `0x35961`) и `AniMesh.dll` SHA-256 +`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e` (RVA +`0x134D0`, `0x10A50`). + +Игнорируемый тест загружает настоящий объект PLANT 4 миссии +AutoDemo и использует его исходную CTLD-привязку Node3: `frame_a = 0`, +`frame_b = 1`, начальное смешивание `0`. В контроллере корневого PLANT нет +записей tag-21. В +закрытой начальной позе запрос, вызванный через обновление камеры приложения, +останавливает движение. Затем тест вручную задаёт время `1` только для +Node3, пересчитывает pose и преобразование присоединения и повторяет тот же +путь обновления камеры; отрезок проходит через проём. Это проверяет подачу +двух заданных в MSH поз в коллизию приложения, а не открытие двери событием +миссии и не её видимое обновление в игровой сцене. + +Интерактивный preview создаёт отдельную запись для каждого отрисовываемого +корневого строения класса `FORT` и его отрисовываемых компонентов. На каждом +движении камера получает текущую Node38-иерархию компонента и его актуальное +преобразование присоединения, переводит выбранные узлы и slot-ы в мировой +запрос и выполняет его после ограничения цели рельефом. Попадание сохраняет +прежнюю позицию камеры, но не отменяет новый поворот; повтор ожидающего кадра +не применяет шаг движения второй раз. Так приложение связывает мировой +MSH-запрос с движением свободной preview-камеры. Выбор первого корня и полный +состав мирового списка ещё не сверены с оригиналом; игровые источники поз и +событий, а также поведение стратегической и следящей камеры остаются +открытыми. + ## Сторона грани в цветовом проходе В цветном проходе `Terrain.dll`, функция `0x100296F0`, сравнивает камеру с diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md index 3085731..08d7c37 100644 --- a/docs/reference/render-frame.md +++ b/docs/reference/render-frame.md @@ -24,22 +24,52 @@ затенения, вызывается завершение рендерера, снимается `in_render`, затем рассылается `end-of-render`. -При retry кадра после пересоздания swapchain сохраняется прежний pending -sample, включая уже вычисленные позы и состояния FX и материалов. Для нового -drawable обновляются aspect, проекция и геометрия, зависящая от viewport; pose -берётся из pending camera. Новые события управления камерой остаются в -input controller и применяются следующим sample. Экранные billboards погоды -повторно проецируются из закэшированных мировых головы и хвоста, без второго -шага симуляции окружения и повторной отправки звуковых событий. Пауза нулевого -drawable не добавляет время. - -Pending frame хранит пакет MAT0 для мира и, при наличии GPU-окружения, отдельный -пакет неба. Пакеты содержат выбранные селектор и категорию конвейера, фазовые -коэффициенты материала, четыре UV-преобразования и кортеж фаз дополнительных -текстур. Повторное применение пакета при retry или нескольких resize не выбирает -фазу заново и не продвигает RNG. Перед отрисовкой сначала применяются материалы -мира и их порядок прозрачности, затем MAT0 неба, после чего viewport-зависимый -проход неба записывает свои переопределения. +В интерактивном preview часы материалов и окружения прибавляют полное время +между обновлениями. Ограничение шага в 100 мс остаётся у движения свободной +камеры и не обрезает эти часы. В режиме replay окружение получает разницу +последних применённых значений общего control clock. + +Если swapchain просит повторить ещё не представленный кадр, preview сохраняет +его общий clock, уже вычисленные позы, FX/RNG и фазы материалов. При изменении +drawable обновляются камера, aspect и viewport, после чего sky, environment +geometry и projected shadows пересобираются для нового размера. +Положение и ориентация камеры берутся из сохранённого состояния +ожидающего кадра; новый ввод мыши применяется при следующем сэмпле. Сэмпл +погоды остаётся прежним: экранные billboards дождя и снега повторно +проецируются из закэшированных мировых головы и хвоста, без второго шага +симуляции окружения или повторной отправки звуковых событий. Следующий такт +выбирается после успешного представления кадра; пауза нулевого drawable не +добавляет время. + +## Прозрачность порталов MSH + +GOG `Terrain.dll` (SHA-256 +`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`), +RVA `0x1B0F0`, заменяет diffuse alpha пакета без type 16 с batch bit `0x0008`. +При `0x0010` аргумент кривой — `sqrt(distance)`, границы — `2.6 / fov` и +`7.7999997 / fov`, наклон — `fov / (7.7999997 - 2.6)`. Без этого бита +`0x0040` задаёт ноль; иначе границы — `75 * 1.3 / fov`, `95 * 1.3 / fov`, +наклон — `fov / (95 * 1.3 - 75 * 1.3)`. До ближней границы alpha равна нулю, +после дальней — единице. Внутри перехода alpha равна произведению смещения от +ближней границы на наклон. Это замена alpha MAT0, а не дополнительный множитель. + +Метод расстояния `0x246A0` через `0x247B0` читает первую индексированную +вершину пакета, преобразует её в мир и вычисляет квадрат расстояния до камеры. +Текущий Ngi32 dispatch `FastProc+0x2C` указывает на RVA `0x1D6D0`: +`RSQRTSS` возвращает приближение обратного корня, которое умножается на квадрат +расстояния. Получается приближённое евклидово расстояние; дополнительный `FSQRT` +ветви `0x0010` выполняется уже над ним. Хеш Ngi32 — +`bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5`. +Скаляр камеры в шестом float getter `0x4F5F0` и поле CShade `+0xBB4` +проверены в работающем оригинале: оба содержат текущий горизонтальный FOV. + +`legacy_msh_portal_alpha` хранит кривую в `fparkan-render`. Приложение пока +берёт первую индексированную вершину своего диапазона и обычный `sqrt`, +поэтому выбор вершины после native CPU clipping и точность `RSQRTSS` не +воспроизводятся. Счётчик `render_coverage portal_fade_packet_selection_approx` +показывает число таких диапазонов. Поток type 16 сохраняет прежний отдельный +путь и ещё не декодирован. Это ограничивает приёмку на частично отсечённых +порталах и не позволяет считать интерьер принятой эталонной сценой. ## Элемент отрисовки @@ -68,6 +98,23 @@ Pending frame хранит пакет MAT0 для мира и, при налич `batch` `0x108`. Такой диапазон при включённой проверке глубины не записывает глубину. За фиксированными диапазонами окружения сохраняются их явные состояния. +Для MSH без потока цветов type 16 диапазон, цвет которого вычисляет CPU, при +raw-флаге `Batch20` `0x0008` получает переопределение alpha только в одном +подтверждённом случае. В GOG `Terrain.dll` с SHA-256 +`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` проверка +по RVA `0x1B0F0` задаёт alpha `0`, когда установлен bit `0x0040` и сброшен +`0x0010`; поэтому для `0x0148` alpha равна нулю. Для `0x0108` и `0x0118` +превью сохраняет alpha MAT0; для `0x0058` действует приоритет bit `0x0010`, +также оставляя alpha MAT0. При наличии type 16 или отсутствии `0x0008` +переопределения нет. Оно относится к одному draw range и не меняет общий +материал; alpha `0` временно задаёт число индексов `0`, а при очистке +переопределения исходное число индексов восстанавливается. + +Общий fade по расстоянию пока не реализован. Открыты точная метрика расстояния, +выбор первого CPU-принятого треугольника диапазона и эквивалентность frustum- и +culling-отбору оригинала. Поэтому состояния `0x0108` и `0x0118` оставляют alpha +MAT0, пока эти входные данные не установлены. + В превью с фиксированной камерой диапазоны туманности и звёзд, купола и облаков явно помечаются как прозрачный фон. Непрозрачные диапазоны мира остаются на своих местах; прозрачная очередь мира сортируется от дальних элементов к @@ -100,6 +147,10 @@ RVA `0x13D90`. Эти данные подтверждают только порÑ путь превью принимает входные данные с матрицей `selector0_words`, полученные через [процедуру захвата нативного кадра](../../tools/native-frame-capture/README.md). +Формат захвата сохраняет локальную матрицу `selector0_words` и, когда она +доступна, записывает `effective_world_matrix_words` с маркером пространства +`world`. + Процедура захвата записывает наблюдавшийся путь миссии и значение `atmosphere_seconds`, если проверка потока, поколения и часов прошла успешно. В превью с фиксированной камерой эта абсолютная фаза расписания используется, @@ -146,14 +197,26 @@ RVA `0x4EBE0` использует соглашение о вызовах `__std события, слушателю задаются захваченные позиция, направление вперёд и верхняя ось; начальная сортировка диапазонов мира использует ту же позицию. +Если в JSON присутствует пара `effective_world_matrix_space: "world"` и +`effective_world_matrix_words`, превью использует мировую матрицу. Матрица +`selector0_words` остаётся локальной и используется как запасной вариант только +при отсутствии мировых полей, если `parented_camera` не равно `true`. Для +родительской камеры без эффективной мировой матрицы вход отклоняется. + Захват может содержать наблюдавшийся путь миссии и проверенное значение `atmosphere_seconds`; последнее используется только при отсутствии явного параметра командной строки. Это значение фиксирует фазу расписания атмосферы -при сравнении с неподвижной камерой, тогда как FX продолжает получать реальное +при сравнении с неподвижной камерой, тогда как FX по умолчанию получает реальное время кадра. `simulation_time_seconds`, погода, RNG и полное состояние миссии остаются неизвестными, поэтому совпадение камеры и фазы не подтверждает визуального совпадения с нативным кадром. +Параметр `--control-clock-ms` воспроизводит единые часы MSH/FX от нуля до +выбранного значения в миллисекундах за указанное число кадров. Без этого +параметра часы идут в реальном времени. Для `--readback-sequence-out` этот +управляемый режим обязателен. Он не восстанавливает +`simulation_time_seconds`, погоду, RNG или полное состояние миссии. + Для неба превью вычисляет направление как `atan2(forward.x, forward.y)`. Оно совпадает со вторым выходным значением, вычисляемым в `Terrain.dll` по RVA `0x4F9C0`; при выборе сектора экранного градиента `CSky` добавляет π по RVA @@ -166,21 +229,50 @@ RVA `0x4EBE0` использует соглашение о вызовах `__std `1.3` рад — параметры, наблюдавшиеся в проверенном контексте GOG Ngi32 ([формула native projection](../tomes/05-render.md#камера-и-размещение-в-оригинале)). Для отношения ширины к высоте `a` превью вычисляет вертикальный угол -```rust -fn vertical_fov(self, aspect: f32) -> f32 { - 2.0 * ((self.horizontal_fov * 0.5).tan() / aspect).atan() -} -``` - -Код заменяет невалидный `aspect` на отношение 16:9; горизонтальный угол -остаётся `1.3` рад после изменения размера окна, а новый viewport и матрица -камеры применяются при следующей отрисовке. +`2 * atan(tan(horizontal_fov / 2) / a)`. Поэтому горизонтальный угол остаётся +`1.3` рад после изменения размера окна; новый viewport и матрица камеры +применяются при следующей отрисовке. Проекция направления не сравнивает косинус угла с ближней дистанцией: она отбрасывает только лучи за камерой и почти параллельные плоскости изображения (`forward · direction <= 1e-4`). Лучи в передней полусфере могут проецироваться за край viewport; владелец спрайта отдельно решает, нужно ли его рисовать. +После движения свободная камера ограничивает X и Y прямоугольником +поверхностей карты, затем поднимается над найденным рельефом на заданный +зазор. Границы берутся из корня пространственного индекса `TerrainWorld`, +поэтому неиспользуемые вершины файла их не расширяют и повторного обхода +геометрии в каждом кадре нет: + +```rust +position[axis] = position[axis].clamp(min[axis], max[axis]); // axis = 0, 1 +position[2] = position[2].max(ground_height + ground_clearance); +``` + +Для каждого отрисовываемого корневого объекта класса `FORT` и каждого его +отрисовываемого компонента интерактивный путь создаёт отдельную запись проверки. +Она использует те же текущие позы узлов и преобразования присоединённых +компонентов, что и геометрия кадра. После ограничения целевой позиции по +TerrainWorld камера проверяет непрерывный отрезок от прежней позиции до цели. +Если после нативных MSH-фильтров остаётся пересечение, позиция возвращается к +началу шага, а рассчитанный из ввода новый поворот сохраняется. Маски batch и +type-7, а также продолжение отрезка по helper-link описаны в +[разделе MSH о сегментном запросе](msh.md#type-7-и-сегментный-запрос). + +Повторная отправка того же ожидающего кадра, например после изменения размера +области отображения, использует уже подготовленное состояние и не применяет +движение второй раз. `--legacy-camera-capture` сохраняет положение захваченной +камеры без изменений и не запускает проверку зданий. Это диагностическое +ограничение, а не восстановленное правило игровой или следящей камеры +оригинала. В проверке начальная поза берётся из декодированных связей CTLD; +проход двери отдельно проверяется после явной выборки записанного в MSH кадра 1 +только для Node3. Так проверяется вход запроса столкновения и путь камеры; +видимая дверь не анимируется, а миссионные команды не воспроизводятся. + +Высота полёта сверху не ограничена. Это ограничение диагностической камеры +превью; точные правила игровых и следящих камер оригинала остаются +неподтверждёнными. + ## Ограничения точного совпадения `VulkanStaticCamera::from_legacy_d3d7` выполняет восстановление вида и проекции @@ -202,6 +294,11 @@ D3D7 в `fparkan-render`, а затем один раз меняет знак в - переход на запасной набор возможностей устройства; - квантование времени симуляции. -Для отладки требуется детерминированный захват кадра: состояние камеры, -идентификаторы видимых объектов, список элементов отрисовки, ключи конвейера, -матрицы и хэши промежуточных буферов. +Параметр `--readback-sequence-out` сохраняет необработанные байты каждого +отправленного кадра; в имя файла записываются индекс кадра, значение общих +управляемых часов и размер изображения. Байты соответствуют выбранному формату +изображения swapchain Vulkan и не гарантируют порядок каналов R, G, B, A. Числовое +формат выводится как `readback_format`; расширение `.rgba` само по себе порядок +каналов не определяет. Последовательность не содержит структурированных ID +видимых объектов, списка элементов отрисовки, ключей конвейера, матриц или +хэшей промежуточных буферов. diff --git a/docs/tomes/03-resources.md b/docs/tomes/03-resources.md index a349a79..f092ece 100644 --- a/docs/tomes/03-resources.md +++ b/docs/tomes/03-resources.md @@ -60,6 +60,12 @@ archive object и увеличивает счётчик владельцев. Г предметного объекта не должен напрямую закрывать архив: он зависит от данных, но не владеет файлом как ресурсом операционной системы. +План ресурсов считает запросы от уникальных визуальных прототипов. Если два +слота материала ссылаются на один MAT0 с одной и той же текстурной фазой, это +два запроса текстуры, хотя подготовленный набор хранит один ресурс. Повторные +ссылки на `lightmap` учитываются так же. `AssetPreparationReport` и ограничения +подготовки считают уникальные подготовленные ресурсы. + ## Имена и пути Большинство игровых имён сравнивается без учёта регистра в ASCII-диапазоне. Это @@ -471,6 +477,15 @@ struct UnitDatRecord112 { Требование обязательного terminator применяется только к полям, где оно доказано форматом. +У AI есть отдельный потребитель Unit DAT: исследовательский chooser читает +заголовок с magic `F0F1`, сохраняет второй DWORD как непрозрачный и завершает +разбор на границе первого полного корневого дерева. `immediate_child_count` +закрывает это дерево по preorder; байты после него, включая неполную запись или +orphan-record, chooser уже не читает. Поэтому Rust для этого пути находит +bounded 112-byte prefix и передаёт его существующим `decode_unit_dat` и +`unit_component_tree`. Общий декодер остаётся строгим к размеру всего файла +`8 + 112 * N`; частный framing AI не меняет этот контракт. + Проверено 425 файлов / 5 219 records Части 1 и 676 файлов / 8 145 records Части 2. Все соответствуют формуле размера, `kind == 1` и `archive_name == "objects.rlb"`. diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 84de6ee..295e718 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -362,30 +362,14 @@ local.set_translation(pose.position); world[n] = world[parent(n)] * local; ``` -Статический Vulkan preview берёт CTLD `.ctl` из явной ссылки прототипа. -Поддерживаемые Control timeline с tag 21 сэмплируются при запуске на native -clock `0`; разрешённые NDP providers передают конечные type-1 значения. -Неподдерживаемый режим или variable mapping, а также отсутствующее или -неразрешённое gate value оставляют компонент на статических CTLD bindings. В -timeline участвуют только включённые строки; при повторном node ID поздняя -строка заменяет раннюю. - -Без `--static-animation-frame` preview сохраняет экземпляр Control и при -каждом redraw сэмплирует поддерживаемый timeline на текущем native clock. -Обновлённая Node38-поза перепроецирует видимую геометрию в заранее выделенный -vertex span, оставляя индексы и draw ranges неизменными. Если в пути Unit DAT -компонента есть такой Control, заново вычисляется его EXTO mount socket и -перепроецируется дочерняя геометрия; дочерний mount использует preorder-порядок -Unit DAT. Внутренние INTO-компоненты здесь не считаются глобальными MSH -socket mounts. Обновлённые вершины и shadow-треугольники попадают в общий -mesh upload перед следующим кадром. Culling sphere восстанавливается от -начальной native sphere и расширяется до текущих shadow-треугольников; это -`shortcut:`, потому что native обновление bounding sphere для анимированной -геометрии пока не установлено. Это путь сэмплирования Control-позы, не AI или -анимация эффектов. - -Явный `--static-animation-frame N` переопределяет timeline и задаёт `N` всем -узлам, пропуская runtime Control-сэмпл. +Статический Vulkan preview сначала строит начальную позу по CTLD `.ctl`. +В ней участвуют только включённые строки; при повторе node ID поздняя строка +заменяет раннюю, а узлы без настройки остаются на кадре `0`. Без +`--static-animation-frame` preview сэмплирует поддержанные Control timelines +при каждом новом сэмпле сцены. Флаг `--static-animation-frame N` задаёт `N` +всем узлам и пропускает runtime Control sample. Это визуальный preview позы, +а не полный Control/AI runtime. + MSH sampler выбирает type 19 frame округлением `(time - 0.5)` к ближайшему целому с ties-to-even, затем интерполирует выбранный type 8 key и следующий key в исходном `time`; это portable-вариант native x87 conversion в default @@ -515,6 +499,31 @@ Material manager кэширует разобранный MAT0 и texture handles лучше вычислять на экземпляр материала, если random offset или локальное время различаются между объектами; immutable phase data остаются общими. +### Почему проём становится прозрачным вблизи + +Портал здания — поверхность, которая закрывает интерьер издалека и постепенно +исчезает при приближении камеры. Если оставить ей постоянную непрозрачность, +вместо окна или перехода получится тёмный прямоугольник. Прозрачность материала +здесь заменяется значением, вычисленным для всего пакета треугольников; alpha +текстуры продолжает участвовать в смешивании. + +Для MSH без потока цветов type 16 правило включает флаг batch `0x0008`. +В обычной ветви при горизонтальном FOV `1.3` поверхность прозрачна до расстояния +75 и непрозрачна после 95; между ними alpha растёт линейно. При другом FOV обе +границы умножаются на `1.3 / FOV`. Расстояние берётся до первой вершины первого +принятого треугольника, а не до центра здания. + +Флаг `0x0010` выбирает другую кривую: сравнивается квадратный корень уже +вычисленного расстояния, границы равны `2.6 / FOV` и `7.7999997 / FOV`. +Например, при FOV `1.04` поверхность на расстоянии 25 имеет alpha примерно +`0.5`, а на расстоянии 5 полностью исчезает. Флаг `0x0040` без `0x0010` +сразу задаёт нулевую alpha; при наличии обоих побеждает `0x0010`. + +FParkan пересчитывает эту alpha при каждом обновлении камеры, после анимации +модели. Нулевое значение временно выключает диапазон индексов; при удалении +камеры диапазон восстанавливается. Подробности оригинального вычисления и +пока приближённого выбора вершины приведены в [описании кадра](../reference/render-frame.md#прозрачность-порталов-msh). + ## Texm: текстуры, mip-уровни и атласы `Texm` -- основной формат изображений. Он хранится в `Textures.lib`, @@ -646,15 +655,15 @@ diffuse texture ломает LOD, atlas coordinates и динамическую Vulkan preview строит проецируемые тени для объектов, отбрасывающих тени, отбрасывает сферы вне шести clip-плоскостей с радиусом `sphere + 100` и сохраняет порядок видимых кандидатов; frame builder обрабатывает не более 20 -объектов. Текущая реализация выделяет страницы в atlas `256 x 256` с 28 page -slots и использует одно сглаживание. Preview применяет native defaults -`RobShadowsOn = true` и `BldShadowsOn = false`: объект kind `4` проверяет -первый флаг, остальные kinds — второй. Поэтому роботы BTLU остаются объектами, -отбрасывающими тени, а объекты FORT и STAT их не создают. Preview пока не -загружает `shade.cfg`, поэтому значения из пользовательского файла не меняют -список объектов, отбрасывающих тени. Проверка действует только на проецируемые -тени: отдельный список объектов для затенения солнца по-прежнему включает -FORT, BTLU и STAT. Вызов preview передаёт `fade = 1.0` и LOD defaults: +объектов, отбрасывающих тени. Текущая реализация выделяет страницы в atlas +`256 x 256` с 28 page slots и использует одно сглаживание. Preview применяет +native defaults `RobShadowsOn = true` и `BldShadowsOn = false`: объект kind `4` +проверяет первый флаг, остальные kinds — второй. Поэтому роботы BTLU остаются +объектами, отбрасывающими тени, а объекты FORT и STAT их не создают. Preview +пока не загружает `shade.cfg`, поэтому значения из пользовательского файла не +меняют список объектов, отбрасывающих тени. Проверка действует только на +проецируемые тени: отдельный список объектов для затенения солнца по-прежнему +включает FORT, BTLU и STAT. Вызов preview передаёт `fade = 1.0` и LOD defaults: minimum LOD `1` и detail multiplier `1.0` отдельно для зданий и роботов. Проекция учитывает вклад directional и point lights. @@ -823,6 +832,39 @@ emit не должен повторно менять игровое состоя типизировать header и resource references, а body редких commands сохранять raw до подтверждения field-level semantics. +В `fparkan-game` выбранные Control FX проходят отдельный transparent path. +Начальная CTL group запускает только строки без predicates, chain или state +gate. Type 4 point list задаёт локальную матрицу; текущие MSH owner и mounted +node poses входят в transform до локальной FX basis. Реализованные visuals +охватывают Op3 modes 0–2, Op4, Op7/8 particle pools и Op9 generated mesh. Op1 +selector 1 добавляет point lights без native flag `0x80000000` в общий diffuse +список; остальные active records сохраняют flags для специальных путей и +projected shadows. Effect renderer подавляет header bit `0x800` только при +render selector 0. Native AniMesh component path захвачен с selector 1, а в +его 53-entry manager присутствуют FX entries с header `0xD00`; preview +использует selector 1 и пропускает такие visual commands. Изменения manager +`A8`, которые задают runtime phase/header gate, пока не моделируются. Header +bit `0x10` относится к activation lifecycle и не выключает draw. + +Для visual effect renderer использует MAT0, указанный самой командой. Его +phase выбирается по normalized FX phase, а не по wall-clock clock материала +мира. FX opacity остаётся в vertex alpha, MAT0 alpha для этой FX binding +фиксируется на единице, texture alpha остаётся в shader. Preview передаёт FX +через SourceAlpha/read-only-depth state. MAT0 phases проходят локальный +eligibility predicate additive-RGB approximation; native `flag40` и +directional-light branch остаются неразрешёнными. + +Op9 генерирует индексированную полусферу с отдельными vertex и UV данными и +отправляет каждый quad и cap как отдельный draw range. Native packet distance +callback берёт первую индексированную вершину (`Terrain.dll:0x246A0` через +`0x247B0`), преобразует её согласно режиму packet и использует евклидово +расстояние до камеры. Queue размещает большие расстояния раньше меньших; +равный ключ вставляется в найденный binary-search midpoint, поэтому порядок +равных packets не стабилен по времени поступления. Preview повторяет этот ключ +и insertion order. Renderer category 0 остаётся highest-detail mesh; native +category вычисляется из projected sphere size и quality table, поэтому +фиксированная категория не является native LOD parity. + ## Полный кадр Крупный вход в world render проходит через `World3D::stdRenderGame`. Доказан @@ -1035,42 +1077,48 @@ WEAR и MAT0 до объединения геометрии. Индексы GPU animation phase; object batches сохраняют их legacy blend/depth/alpha state. После загрузки world path создаёт интерактивную free-flight камеру (`WASD`, -`E/Q`, `Shift`, `Ctrl`, RMB relative-look), обновляет listener и собирает -environment frame для каждого подготовленного кадра. Event loop один раз -сэмплирует камеру, общий animation clock, геометрию и окружение перед запросом -redraw; redraw потребляет это состояние. Если surface требует пересоздать -swapchain, тот же sample времени и состояние мира отправляются повторно. -Перед повторной отправкой projection камеры и viewport-зависимые environment -и shadow geometry обновляются для нового размера без продвижения сцены. -Счётчик и replay clock продвигаются только после результата `Presented`. -После шага движения камера проверяет высоту -`TerrainWorld`: над конечной поверхностью сохраняется начальный просвет, а вне -поверхности высота не ограничивается. Mission-local `sky.ske`/`sky.wea` выбирают +`E/Q`, `Shift`, `Ctrl`, RMB relative-look), обновляет listener и на каждом +кадре собирает environment frame. Для каждого отрисовываемого строения +(корневого объекта класса `FORT`) и для каждого его отрисовываемого компонента +создаётся отдельная запись проверки. Она использует те же текущие матрицы узлов +и присоединённых компонентов, что и геометрия. После ограничения целевой +позиции по TerrainWorld камера проверяет непрерывный отрезок от прежней позиции +до цели; при пересечении +она остаётся в прежней позиции, сохраняя новый поворот. Повторная отправка +ожидающего кадра не применяет движение второй раз. Это ограничение +диагностической свободной камеры, а не правило игрового или следящего режима +оригинала. +Mission-local `sky.ske`/`sky.wea` выбирают атмосферное расписание и sky materials, `effects.rlb/env_lightning` даёт lightning visual; weather particles, flares, sun/moon, point lights, projected shadows и audio events обновляются вместе с камерой и временем. `--atmosphere-seconds` задаёт старт времени, `--frames 0` оставляет цикл -бесконечным, `--control-clock-ms N` воспроизводит общий clock анимации MSH и -материалов вместе с окружением от нуля до `N` за заданное число кадров и требует -как минимум два кадра. -`--preview-roots` служит только диагностическим ограничителем. +бесконечным, а `--preview-roots` служит только диагностическим ограничителем. `--legacy-camera-capture` выбирает воспроизводимую камеру без free-flight -управления: запись selector 0 задаёт D3D7 transform, clip параметры и native -viewport RECT `[left, top, right, bottom]`. Путь сохраняет оси basis из view -matrix вместе с roll, переносит полученный `clip_from_world` в preview camera, -переводит RECT в физический drawable размер `(right - left) × (bottom - top)` -и отклоняет несовпадение размера при -создании или resize вместо растягивания кадра. Результат нужно сравнивать с -тем же crop исходного PNG `[left, top, right, bottom]`. Контроллер -`FreeFlightCamera` в этом режиме не создаётся. Подготовка кадра обновляет +управления. `selector0_words` содержит локальную матрицу камеры. Если запись +содержит `effective_world_matrix_words` с маркером +`effective_world_matrix_space="world"`, preview использует эту мировую +матрицу. При `parented_camera=true` отсутствие мировой матрицы — ошибка; +локальный selector 0 остаётся legacy fallback только для записей без признака +parented и без мировой матрицы. +Если native helper явно применяет выбранную мировую камеру, setter работает в +mode 2, а restore возвращает исходную локальную матрицу в mode 1. Clip +параметры и native viewport RECT `[left, top, right, bottom]` идут из той же +записи. Путь сохраняет оси basis из view matrix вместе с roll, переносит +полученный `clip_from_world` в preview camera, переводит RECT в физический +drawable размер `(right - left) × (bottom - top)` и отклоняет несовпадение +размера при создании или resize вместо растягивания кадра. Результат нужно +сравнивать с тем же crop исходного PNG `[left, top, right, bottom]`. Контроллер +`FreeFlightCamera` в этом режиме не создаётся, но redraw продолжает обновлять environment и projected shadows от captured camera. Перед environment update и его audio events listener получает position, forward и up captured camera; начальная сортировка world draw использует её же position. Phase capture может содержать наблюдаемый mission path и проверенный `atmosphere_seconds`; viewer использует эту абсолютную фазу по умолчанию, если не указан явный CLI override, -и удерживает sample атмосферы на ней через readback frames. Без -`--control-clock-ms` FX получает реальный frame time; с параметром environment -и материалы используют replay clock. Это не полное simulation time: `weather`, RNG и +и удерживает sample атмосферы на ней через readback frames. Фазы FX следуют +выбранному control clock: без `--control-clock-ms` он обновляется по elapsed +time, а с явным значением readback frames переигрывают заданный clock. Это не +полное simulation time: `weather`, RNG и остальное mission state остаются неизвестными, поэтому совпадение камеры и фазы не доказывает visual parity с native frame. diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 48f4405..6edfb7a 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -168,8 +168,11 @@ handler лишь инициирует execution context (`+0x50 = 1`); его ga младшие 32 бита, что даёт `0`. Значение `10001` также проходит дальше: оригинал показывает предупреждение для чисел выше `10000`, но не отменяет создание AI. -Rust исполняет только подтверждённую форму этого действия. Поддержанные записи -применяются к состоянию лишь после успешного прохода всего первого `Init`. +Rust исполняет подтверждённый поднабор первого `Init`: действие `Handler(19)` +и формулы из одного атома либо двух атомов с бинарным `+` или `-`. Здесь атом — +число или имя переменной. Поддержанные шаги сразу обновляют временную копию +varset, поэтому следующая формула читает уже записанные значения. К состоянию +клана записи попадают лишь после успешного прохода всего первого `Init`. Если встретилась неподдержанная форма, preview сохраняет пакет, сообщает `Unsupported` и оставляет значения по умолчанию; при полной загрузке та же форма становится ошибкой. Это политика Rust для ограниченного исполнителя, а не правило @@ -177,8 +180,62 @@ Rust исполняет только подтверждённую форму эÑ событие считается исполненным без записей. Остальные события пока не образуют исполняемую VM. +В `Init` встречаются и отдельные присваивания результатов формул. Их запись +выбирает строку формулы по порядковому номеру из соседнего `.fml`; этот файл +относится к конкретной базе сценария, а строки после нужного номера сохраняют свои позиции. +Для первой кампанийной миссии `Mission.01` (`tut1_pl2.fml`) строка 0 записывает +нули в `df0..df3`; в миссии `Single.01` (`scr_pl_1.fml`) строка 0 записывает +нули в `dT8` и `dT9`. +Клан 1 той же миссии использует первые девять строк `scream.fml`: восемь +проверенных значений попадают в `DWORD` с усечением дробной части, а `0.8` +сохраняется как число `float`. Например, если `source` равен `10`, выражение +`source + 2.0` записывает `12`; следующее `target - source` читает новое значение +`target` и получает `2`. Каждая операция работает с `f32`: значение ячейки +`DWORD` сначала читается как беззнаковое число и переводится в `f32`, а результат +сохраняет тип целевой ячейки. Поддерживаются только один или два атома и один +бинарный `+` либо `-`; произвольная грамматика формул и остальная VM не +восстановлены. В отдельной точечной проверке вычисления и записи оригинальной +DLL и Rust совпали для 13 выбранных формул. Эта сверка проверяет только +эти значения и не запускает исходную миссию. Тест полной загрузки подтверждает, +что `Mission.01` проходит этот `Init`; у `Single.01` следующий неподдержанный +`Handler(43)` встречается в clan 1, записи 10. Никакое значение не подставляется +вместо этого действия. + ### Первый запуск сценария +Перед настройкой первого шага каждый AI получает начальный массив DWORD. В +`iron3d.dll` SHA-256 +`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa` +`FUN_100392e0` (RVA `0x392e0`) повторяет вызов слота AI `+0x38` столько раз, +сколько задаёт `second_resource.tag` этой строки TMA. Этот слот ведёт в +`ai.dll` SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`: +метод RVA `0x2010` передаёт коллекцию при `AI+0x83c` в `FUN_10002980` +(RVA `0x2980`), а она добавляет один DWORD через `FUN_100048f0` (RVA +`0x48f0`). Каждый вызов копирует `0xffffffff`; входное слово не меняется. +Поэтому тег `3` даёт `[0xffffffff, 0xffffffff, 0xffffffff]`. Это число +начальных ячеек, а не назначение трёх ID. + +Изолированная x86-проверка вызовом оригинальной `ai.dll` подтвердила пары +«число слотов/ёмкость» `0/0`, `1/17`, `17/17` и `18/34`. Все добавленные слова +равны `0xffffffff`, а входное значение, контрольные поля, память вне заголовка +массива и указатель стека сохраняются. Это подтверждает добавление начального +слова, но не работу последующих обработчиков. + +Rust хранит такой снимок в отдельном `MissionAiStartupState` для каждого TMA-клана, +а не в пакете сценария. При полной загрузке (`Full`) создаётся запись для каждого +клана, в том числе если у него нет пути сценария или пути таблицы исследований. Счётчик +берётся из сырых битов `second_resource.tag` как `u32` у обеих форм записи — +`Standard` и `Spatial`; нулевой счётчик даёт существующее состояние с пустым +списком. Статический режим предпросмотра не создаёт состояний AI. + +Загрузка отклоняет суммарный объём свыше 1 000 000 DWORD и резервирует память +проверяемой операцией; это защита Rust от чрезмерного выделения памяти, а не +ограничение оригинальной игры. Rust сохраняет начальные слова, но не +восстанавливает работу последующих обработчиков и не назначает этим словам ID +объектов. Этот массив также не является списком +`BehaviorDistributorState.registered_objects`. + До регулярной работы Behavior настраивает расписание своего первого шага. Он берёт текущее 32-битное время игровой очереди и назначает проверку через три интервала по 64 мс, добавляя небольшую случайную задержку от 0 до 63 мс. Эта @@ -196,9 +253,10 @@ SuperAI для записей кланов, затем обновляет Behavi от повторного входа; при занятой защите вызов пропускается, но одноразовая метка всё равно устанавливается. В полной загрузке Rust вызывающая сторона передаёт значение времени очереди; после регистрации объектов загрузчик последовательно -подготавливает каждый сценарный пакет и выполняет только подтверждённые действия -`Handler(19)` из первого `Init`. Статический просмотр не запускает Behavior, но -может сохранить подтверждённые записи `Init` в сценарном varset. +подготавливает каждый сценарный пакет и выполняет восстановленные действия +`Handler(19)` и поддержанные формульные присваивания из первого `Init`. Статический +просмотр не запускает Behavior, но может сохранить эти записи `Init` в сценарном +varset. Это полезное начало сценария, но ещё не целая игровая жизнь AI. Текущий runtime не воспроизводит аргумент обратного вызова в `AI+0x3fc`, защиту от повторного @@ -243,6 +301,25 @@ Rust-тест повторно читает лицензированную миÑ TMA-записями и проверяет реальные `ObjectHandle`, но помечен `#[ignore]`, чтобы не требовать оригинальных ресурсов от остальных разработчиков. +После подготовки Control-таблицы можно разобрать и её начальную группировку для +Behavior. Важны текущая метка записи и её порядковый номер в общей таблице: +метка `1` начинает пустую группу, метка `2` становится кандидатом последней +группы, а метка `30` записывает строку в отдельный список той же группы. +Кандидат, встретившийся до первого заголовка, остаётся в списке без группы; +ранняя метка `30` игнорируется. Например, +для текущих меток `[2, 30, 1, 30, 2]` получаются незагруппированный кандидат +`[0]` и группа с заголовком `2`, кандидатом `[4]` и строкой с меткой `30` под +порядковым номером `[3]`. Все номера начинаются с нуля и отсчитываются по общей +Control-таблице, а не по ключам устройств или записям CTLD. + +Полная загрузка Rust сохраняет это начальное состояние рядом с подготовленными +Control-таблицами по исходному TMA ID и `ObjectHandle`. Если таблица недоступна, +проекция сохраняет причину ошибки; доступная пустая таблица означает пустую +группировку. При предварительном просмотре список группировок не создаётся. Здесь +разбирается только подготовленный порядок текущих Control-дескрипторов. Проекция +не устанавливает, когда список присоединяется к живому Behavior, как он затем +обновляется и как работает полный игровой цикл. + Список распределителя хранит не только участников, но и долю каждого. При добавлении двух объектов оба получают по половине общего веса. Если добавить третий, движок оставляет долю новой записи около одной трети, а оставшийся вес @@ -468,16 +545,117 @@ Tag выбирает конкретное правило. Для tag 1 запиÑ ### TRF и preload-данные -TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial -variants имеют сигнатуру [NRes](../reference/nres.md) и содержат большие -таблицы имён игровых прототипов: оружия, башен, сооружений и других объектов. -Также найдены preload-записи, ANI и SKE resources. +TRF-архивы используют несколько потребителей; один подтверждённый путь ведёт +из `Handler(43)` к вычислению стоимости исследования. В проверенной сборке GOG +`CalcFullResearchCost` из `ArealMap.dll` получает таблицу, загруженную через +`MisLoad.dll`. Для этого пути `TRF0` содержит строки по десять сырых `u32`, +`TRF1` — байты флагов, `TRFB` — пары «смещение имени и номер строки», а +`TRF6` — пул строк с нулевыми окончаниями. Слово 8 строки связывает её с +`TRFB` через младшие 16 бит; старшая половина сохраняется как неизвестное +значение. `TRF2` и `TRF3` образуют упорядоченные списки предварительных +исследований, которые читает этот путь стоимости. `TRF4`/`TRF5` образуют +отдельную пару таблиц, но `CalcFullResearchCost` её не использует. Непрозрачные +`TRF7`–`TRFA` остаются в архиве и требуются загрузчику. + +Модуль `fparkan-assets` разбирает этот профиль в `ResearchTrfDocument` и +вычисляет пару результатов для одного Unit DAT: -По содержимому, порядку загрузки и consumers TRF с высокой вероятностью -предоставляет AI/сценарному слою заранее подготовленную таблицу типов и -связанных данных. Framing и имена подтверждены corpus-ом, но полная семантика -каждой TRF-записи ещё не закрыта. Имена должны разрешаться через тот же -resource registry, что и миссионные объекты. +```rust +let table = ResearchTrfDocument::decode(archive_bytes)?; +let cost = calc_full_research_cost(&candidate, &table)?; +``` + +Расчёт проходит компоненты Unit DAT в порядке дерева. Компонент с нулевым +`kind` завершает весь свой участок вместе с потомками. Для остальных ключ +ресурса ищется среди имён `TRFB`; повтор строки добавляется лишь один раз. +После прямых компонентов алгоритм обходит список `TRF2`/`TRF3` в его исходном +порядке, углубляясь сразу в найденные предварительные исследования. Общая +таблица посещений не даёт повторно добавлять узел и завершает циклы. Два бита +флагов `TRF1` управляют включением строки и обходом её зависимостей; остальные +сырые биты и слова не получают игровых имён без отдельного доказательства. + +Результат первого native-выхода подписан в отладочной строке как ore и +суммирует слово 1 `TRF0`, второй подписан как power и суммирует слово 0. Каждое +сложение сохраняется как `f32` в порядке найденной последовательности. В +принятом наборе из 23 синтетических тестов оригинальных DLL проверялись +`CalcFullResearchCost` и два AI-предиката; именно вызовы `CalcFullResearchCost` +подтвердили порядок обхода, дедупликацию, циклы, флаги и отсечение дерева. +Две проверки суммы показали чувствительность округления: одна и та же сумма +`1, 2^24, -2^24` даёт `0`, а в другом порядке `2^24, -2^24, 1` даёт `1`. + +Эти результаты относятся к синтетическому исследовательскому контексту; +штатный Handler(43) и запуск миссии здесь не проверялись. + +Владелец начальной записи исследования — индекс текущего прохода по кланам. +В GOG-сборке `iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) проходит записи кланов +и вызывает для каждой `FUN_100392e0` (RVA `0x392e0`), которая создаёт AI; +`ai.dll!CreateSuperAI` (RVA `0xf710`) выделяет объект и вызывает конструктор +по RVA `0x1000`, не проверяя наличие сценарного пути. Для исследовательского +пути нужны отдельный флаг включения и непустой `second_resource.path`; наличие +сценарного пакета условием не является. Индекс клана сохраняется в +`descriptor[0]` и переносится в контекст, чтобы связать запрос с AI-данными +того же клана. + +Это значение отдельно от аргумента 1 `AddNewObjectToGame`, берущегося из +`GetNetPlayerNum`, и от TMA-тега `second_resource`, задающего число вызовов +AI-слота `+0x38`. Передача поля подтверждена по статическому коду; выполнение +Handler(43) и штатный запуск миссии остаются непроверенными. Технические +сведения и адреса исходных сборок приведены в [границах знания](../appendices/knowledge-boundaries.md). + +Полный Rust-загрузчик принимает явный `MissionStartupInput`: время очереди +и отдельный `research_creation_gate`. Режим миссии и имя файла этот +флаг не заменяют. При включённом флаге загрузчик использует уже имеющееся +`MissionAiStartupState` каждого TMA-клана, даже если у него нет сценарного +пакета; путь берётся из второго ресурса как C-строка до первого NUL. Пустой +путь и выключенный флаг пропускают чтение, а не-ASCII байты дают явную ошибку. +Предпросмотр тоже не читает TRF. Клан индексируется по порядку в TMA, а тег +`second_resource` задаёт число слотов AI, не владельца. Если несколько кланов +указывают на один файл, каждый получает отдельно декодированную таблицу со +своими строками TRF0. + +```rust +MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: true, +} +``` + +Это сохраняет декодированные таблицы и связь с владельцами для следующих +этапов. Оно ещё не создаёт полный исследовательский контекст оригинала, не +строит AI-каталог и не запускает `Handler(43)`; эти части пока не +восстановлены. + +Отдельная frozen-матрица непосредственно вызвала chooser GOG `ai.dll` SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`: функция +`FUN_10010f90` (RVA `0x10f90`) прошла три guard-case и все 15 проверок. +Конструктор находится в RVA `0x10480`, raw DAT parser — `0x102c0`, +предикаты — `0x115f0` и `0x11530`. Матрица передавала кандидатов в заданном +порядке. Обёртка `Handler(43)` по RVA `0xd54a` и загрузчик AI-каталога по RVA +`0x104e0` не вызывались. Cost принадлежит `ArealMap.dll` SHA-256 +`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`, +`CalcFullResearchCost` RVA `0x16280`; для сравнения x87 был явно задан как +`0x007f`. + +`ResearchCandidateSet` держит упорядоченный каталог, выбранный индекс, байт +scan-status и флаг `loaded` каталога; TRF-контекст передаётся отдельно. +При наличии каталога и контекста set-status начинает со значения `1`, затем +перед чтением каждого entry со status `0` становится `0`. Ненулевой entry +пропускается. Первый предикат требует info words 1 и 2, соответствующие битам +TRF1 0 и 2; второй требует words 0 и 2, то есть биты 1 и 2. Второй status +сохраняется в entry, а cost вызывается даже при status `1`. Выбор записывается +сразу, когда second predicate ложен и ore строго ниже текущего минимума, +изначально `f32::from_bits(0x4b18967f)`. Равенство сохраняет первого, NaN индекс не меняет. + +В Rust ошибка VFS-чтения и невалидный DAT пропускают entry; неизвестный TRF- +ключ даёт false predicate. Ошибка стоимости `EmptyResearchTable` тоже +пропускает entry и сохраняет его второй status. Остальные ошибки таблицы и +`UnsupportedNonAsciiLookup` возвращаются явно, причём уже сделанные изменения +индекса и status остаются. Общий Rust-предикат может закончить обход раньше +native callback `0x11530`, который продолжает накапливать неиспользуемые float +sums. Первый проход уже проверил enabled keys в той же неизменной таблице, +поэтому подтверждается эквивалентность состояния и выбора для этого входа, +но не callback-trace parity. Это не подтверждение цены на живой миссии или +порядка AI-каталога. ### Стабильность AI-слоя @@ -883,21 +1061,41 @@ descriptor tag `19` и положительным selector-0 ответом; пÑ его vtable slot `+0x10` статически указывает на общий getter RVA `0x21d00`, но отдельный aggregate oracle этот класс не запускал. Rust хранит начальный scale factory `9` и предоставляет `query_loaded_mission_control_resource` для selector -`1`/`2` и `set_loaded_mission_control_multiplier` для setter-а `1`. Вызовы -сначала проверяют живой `ObjectHandle`, затем обращаются к таблице этого объекта -из полной загрузки миссии. Синтетические тесты проверяют границы пустой таблицы, -нулевого знаменателя, неподдержанных factory tags и чисел за диапазоном -нормального `f32`; лицензированный тест сверяет реальные descriptor tags и -получает для `min_l_n1.dat` сумму `16` и отношение `1` до setter-а. - -В этом API пока реализованы только getter selectors `1`/`2` и setter `1`. +`1`, `2` и `15`, а также `set_loaded_mission_control_multiplier` для setter-а +`1`. Вызовы сначала проверяют живой `ObjectHandle`, затем обращаются к таблице +этого объекта из полной загрузки миссии. Синтетические тесты проверяют границы +пустой таблицы, нулевого знаменателя, отсутствующих объектов фабрик и чисел за +диапазоном нормального `f32`; лицензированный тест сверяет реальные descriptor +tags и получает для `min_l_n1.dat` сумму `16` и отношение `1` до setter-а. + +Сырой селектор `0x0f` соединяет два ранее созданных объекта владельца: у строки +с `factory_tag = 9` берётся коэффициент и его текущий множитель, а у строки с +`factory_tag = 21` — шесть текущих `coefficient_bits`. `INTO` может поменять +`descriptor.tag`, не заменяя объект фабрики, поэтому query выбирает последние +строки по `factory_tag`, а не по текущему tag. Если любой объект фабрики +отсутствует, ответ — `None`. Иначе шесть коэффициентов складываются с индекса `5` +к `0` с округлением до `f32` после каждого сложения; результат умножается на +selector `0` фабрики `9` (`coefficient_bits[0] * class9_selector0_scale_bits`). +Общие `multiplier_bits`, `node_provider_status` и `native_word_50_bits` в этой +формуле не участвуют. + +Лицензионная проверка загружает AutoDemo с явно переданными +`MissionStartupInput { queue_time_ms: 100_000, research_creation_gate: false }` +и вызывает `query_loaded_mission_control_resource(..., 15)` для исходных +объектов `0..5`; для `6` и `7` Rust возвращает `None`, потому что не хватает как +минимум одного объекта фабрики. Native-биты для `0..5` приведены в технической +главе. Проверка сверяет результат исходного геттера на снимке входных полей, +подготовленном Rust. Время и флаг заданы самим тестом; они не доказывают +начальный режим запуска AutoDemo в оригинале и не являются захватом его фабрик +или состояния объекта Control. + Отдельный native setter selector `17`, tag-specific методы прочих factory tags, их внешние эффекты и вызов из регулярного шага Behavior здесь не моделируются. Числа должны оставаться конечными и нормальными либо точным нулём; произведения -и отношения за поддерживаемым диапазоном `f32` возвращают явную ошибку, а не -подменяются нулём или `None`. При проверке диапазона Rust оценивает операцию в -`f64`, затем выполняет её в `f32`; это ограниченная проверка диапазона, а не -эмуляция полного x87 accumulator-а. +и суммы за поддерживаемым диапазоном `f32` возвращают явную ошибку, а не +подменяются нулём или `None`. При проверке диапазона Rust сначала оценивает +границы в `f64`, затем выполняет каждую операцию в `f32`; это сохраняет +пошаговое округление этого getter-а, но не эмулирует весь x87 execution state. Отдельная ветка того же GOG `AniMesh.dll` показывает, когда создаётся Behavior для host object. В `FUN_10002ff0` (RVA `0x2ff0`, SHA-256 @@ -941,8 +1139,31 @@ NDP, а `FUN_1000a100` (RVA `0xa100`) добавляет их в общую та последовательность provider-строк: её длина берётся из NDP, она не дополняется до числа узлов MSH и не участвует во второй перенумерации ключа. -Так этот селектор включён в цепочку добычи руды оригинала. В той же сборке -`Behavior.dll` (SHA-256 +Индексный selector `0x400` выдаёт значение выбранной строки provider как есть. +Сначала общий индекс устройств выбирает дескриптор, затем его ключ выбирает +строку NDP; это разные номера. Результатом становится текущее type-1 значение +этой строки, а не запас `CurrentOre`, предел или значение после умножения на +коэффициент и `M`. Например, provider-строка с `0.625` возвращает `0.625`, а +записанный в ней ноль тоже остаётся успешным результатом. Rust использует уже +подготовленную таблицу и сообщает отдельную ошибку, если provider-таблица или +строка по ключу недоступна. Этот запрос читает снимок состояния и сам не меняет +таблицу и не запускает её обновление. + +У правила есть два разных подтверждения. Разбор AutoDemo нашёл восемь таблиц +Control и 100 устройств; все 100 значений по ключам устройств в этом корпусе +равны `1.0`, поэтому Rust-проверка подтверждает реальные ключи и доступность +строк, но сама по себе не различает выбор по ключу и по номеру устройства. +Отдельная x86-проверка исходного `Control.dll` включала шесть вызовов на +синтетической таблице, где значения строк были заданы вручную и различались. +Например, при номере устройства `2`, ключе `1` и нулевом слове +`device+0x50` оригинал вернул `0.625` из выбранной строки. Точные биты ответов +приведены в [техническом разборе](../appendices/script-vm.md#индексный-control-getter-сырой-селектор-0x400). +Этот опыт не создавал оригинальные factory-объекты и не исполнял полную миссию: +полный интерфейс IID `0x204`, интеграция Behavior и игровое поведение остаются +непроверенными. + +В цепочке добычи руды оригинал использует описанный выше selector `0x300`. +В той же сборке `Behavior.dll` (SHA-256 `ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) `FUN_100186b0` (RVA `0x186b0`) перечисляет устройства типа `0x1a`, а `FUN_100198e0` (RVA `0x198e0`) суммирует успешные запросы IID `0x204` с selector @@ -1221,6 +1442,30 @@ descriptor через `INTO`. Так разные части Control могут загруженной таблицы; обновление пока не подключено к игровым часам и не заменяет полный Control tick. +Устройство фабрики `2` имеет поле selector `6`, `+0x174`; конструктор задаёт ему +ноль. Позднее Control может пересчитать его по паре строк записи и отдельному +прототипу `BULL`. Только для поддержанной свежей формы одного корневого mesh +`BULL` из `objects.rlb`, без дочерних компонентов, его NDP-строки сводятся к +сумме Max; например, `[1.5, 2.25]` +превращается в `3.75` с округлением `f32` после каждого шага: + +```rust +let mut total = 0.0_f32; +for row in ndp_rows { + total += row.max_volume; +} +``` + +Пустая таблица даёт ноль; физическую единицу этому числу не приписываем. Пара +сравнивается как две C-строки до первого NUL: хвост не учитывается, регистр не +важен. Если после изменения любая строка пуста, старое `+0x174` сохраняется; +пустая пара конструктора не вызывает пересчёт, потому что уже совпадает. Новые +значения применяются при добавлении и первой активной записи `INTO` в исходном +порядке. Непредставленные входы оставляют селектор недоступным, а ошибка чтения +узнаваемого, но повреждённого ресурса отменяет новую загрузку и сохраняет +прежние мир и RNG. Общая формула selector 35 и условия свежей формы разобраны в +[техническом приложении](../appendices/script-vm.md). + Отдельный путь объекта класса `4` умножает интервал на контактный коэффициент и передаёт изменение строке. В проверенном примере коэффициент `1`, интервал `2`, и значение строки уменьшается на `2`; этот коэффициент не является ставкой @@ -1354,7 +1599,7 @@ offsets 119–121. В `fr_l_gener.ctl` найдено 35 строк type 4, вк `f_gener_ball` с offset 85. Control.dll `+0x8B10` передаёт указатель текущей записи диспетчеру `+0x2D4B0`. -Для tags `2`, `3`, `4`, `5`, `8`, `10`, `21` и `27` подтверждён общий читатель +Для tags `2`, `3`, `4`, `5`, `8`, `10`, `17`, `21` и `27` подтверждён общий читатель `+0x21D50`: таблица виртуальных методов tag `2` (`+0x3CBD8`) вызывает его через обёртку `+0x29650`, остальные перечисленные vtable указывают прямо на него в позиции `+0x1C`. Читатель потребляет @@ -1365,6 +1610,28 @@ Control.dll `+0x8B10` передаёт указатель текущей запÐ а также отказ при оборванном завершающем NUL. Это подтверждает структурный формат, но не назначает смысл всем коэффициентам устройств. +Путь записи с меткой `17` проверен на GOG `Control.dll` с SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`: +ветвь `0x11` фабрики `+0x2D4B0` вызывает конструктор `+0x24760`, который +устанавливает указатель на виртуальную таблицу `0x1003C840`. Ячейка `+0x1C` +этой таблицы ссылается прямо на общий читатель `+0x21D50`. В ресурсах +`weapon.rlb:bm_m_01.ctl` и `weapon.rlb:bm_l_01.ctl` таблица привязок +заканчивается на смещении `0x120`, где начинается запись с меткой `17`. +Общий префикс размером `0xB0` байт включает саму метку; счётчик по `+0xAC` и +слово длины в этих двух ресурсах равны нулю, поэтому указатель чтения +переходит на `0x1D4`. Там начинаются `0x54` байта заголовка таблиц эффектов; +таблицы содержат соответственно `4`, `2`, `2` и `2` строки по 100 байт и +заканчиваются на `0x620`. Записи из реестра объектов `bm_m_01` и `bm_l_01` +при обычной подготовке ресурсов сохраняют явно указанные списки точек CPT +(`bm_m_a.cpt` и `bm_l_a.cpt`) и таблицы узлов NDP (`bm_m_01.ndp` и +`bm_l_01.ndp`); в первой таблице эффектов находятся три строки type 4. +Синтетический тест проверяет непустые массивы значений и строки у записи с +меткой 17 и следующей записи с меткой 4, затем переход к таблице эффектов. +Если считать 17 неизвестной, чтение остановится перед +ней; общий разбор позволяет пройти запись и прочитать следующие таблицы, +оставляя слова tag 17 числами без толкования. Обработчики этой метки и их +игровое поведение ещё не восстановлены. + Resolver прототипов хранит явную registry-ссылку на `.cpt` отдельно от ссылки на `.ctl`. Если строки type 4 разобраны и явная ссылка на CPT есть, подготовка читает ровно указанные archive/name; имя CPT не выводится из имени CTL. В CPT diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md index 428f8c2..75f47e1 100644 --- a/tools/native-frame-capture/README.md +++ b/tools/native-frame-capture/README.md @@ -110,3 +110,41 @@ or `44`/`50` for BGRA. Pass the viewer log's `readback_format` value; `37` is the verified value for the run above, not a universal surface format. The converter uses the installed `System.Drawing` PNG encoder and adds no package dependency. + +## Сравнение областей кадра + +`compare-frames.ps1` собирает пару «оригинал / FParkan» без масштабирования и +считает средний RGB каждой указанной области. Координаты областей относятся к +viewport, то есть к PNG просмотрщика. Оригинальный PNG сначала обрезается по +`viewport` из JSON камеры. Имена выбираются для конкретной сцены: например, +`sky`, `terrain_near`, `terrain_far`, `facade`, `opening`; невидимые в этом +ракурсе области добавлять не нужно. Панели HUD и движущиеся объекты следует +оставлять вне измеряемых прямоугольников. + +Пример `regions.json` для интерьера 1280 × 1024: + +```json +[ + {"name":"ceiling_near","rect":[250,50,550,170]}, + {"name":"wall","rect":[120,400,200,150]}, + {"name":"opening","rect":[650,550,220,100]} +] +``` + +```powershell +& '.\tools\native-frame-capture\compare-frames.ps1' -SelfTest +& '.\tools\native-frame-capture\compare-frames.ps1' ` + -NativePng 'target\captures\r1\constructor\interior.png' ` + -ViewerPng 'target\captures\r1\constructor\interior-after.png' ` + -CameraJson 'target\captures\r1\constructor\interior.json' ` + -RegionsJson 'target\captures\r1\constructor\interior-regions.json' ` + -OutputPrefix 'target\captures\r1\constructor\interior-comparison' +``` + +Результат — PNG с парой и отмеченными областями, а также JSON со средними +значениями каналов в диапазоне 0–255. Это значения сохранённого изображения, +без перевода в линейное цветовое пространство. Скрипт отклоняет несовпадающие +размеры, области за границей кадра и существующие выходные файлы. Совпадение +средних цветов не доказывает совпадение геометрии, поэтому пару нужно также +просмотреть. Камера и фаза должны происходить из одного нативного захвата; +интерьер снимается после настоящего входа игрока в здание. diff --git a/tools/native-frame-capture/compare-frames.ps1 b/tools/native-frame-capture/compare-frames.ps1 new file mode 100644 index 0000000..2a83bf0 --- /dev/null +++ b/tools/native-frame-capture/compare-frames.ps1 @@ -0,0 +1,106 @@ +[CmdletBinding(DefaultParameterSetName = 'Compare')] +param( + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$NativePng, + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$ViewerPng, + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$CameraJson, + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$RegionsJson, + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$OutputPrefix, + [Parameter(Mandatory = $true, ParameterSetName = 'SelfTest')][switch]$SelfTest +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +function Get-MeanRgb { + param([System.Drawing.Bitmap]$Bitmap, [System.Drawing.Rectangle]$Region) + if ($Region.X -lt 0 -or $Region.Y -lt 0 -or $Region.Width -le 0 -or $Region.Height -le 0 -or + $Region.Right -gt $Bitmap.Width -or $Region.Bottom -gt $Bitmap.Height) { + throw 'Region must be a nonempty rectangle within the image.' + } + $sum = [long[]]@(0, 0, 0) + for ($y = $Region.Top; $y -lt $Region.Bottom; $y++) { + for ($x = $Region.Left; $x -lt $Region.Right; $x++) { + $pixel = $Bitmap.GetPixel($x, $y) + $sum[0] += $pixel.R + $sum[1] += $pixel.G + $sum[2] += $pixel.B + } + } + $count = [long]$Region.Width * $Region.Height + return ,@($sum | ForEach-Object { [Math]::Round($_ / $count, 4) }) +} + +if ($SelfTest) { + $test = [System.Drawing.Bitmap]::new(2, 1) + try { + $test.SetPixel(0, 0, [System.Drawing.Color]::FromArgb(10, 20, 30)) + $test.SetPixel(1, 0, [System.Drawing.Color]::FromArgb(30, 40, 50)) + $mean = Get-MeanRgb $test ([System.Drawing.Rectangle]::new(0, 0, 2, 1)) + if (($mean -join ',') -ne '20,30,40') { throw 'Mean RGB self-test failed.' } + $rejected = $false + try { Get-MeanRgb $test ([System.Drawing.Rectangle]::new(1, 0, 2, 1)) | Out-Null } + catch { $rejected = $true } + if (-not $rejected) { throw 'Out-of-bounds region was accepted.' } + Write-Output 'self-test: exact region RGB means and bounds rejection OK' + } finally { $test.Dispose() } + return +} + +$pngPath = [System.IO.Path]::GetFullPath($OutputPrefix + '.png') +$jsonPath = [System.IO.Path]::GetFullPath($OutputPrefix + '.json') +if ((Test-Path -LiteralPath $pngPath) -or (Test-Path -LiteralPath $jsonPath)) { + throw 'Comparison output already exists; choose a new prefix.' +} +$camera = Get-Content -LiteralPath $CameraJson -Raw | ConvertFrom-Json +if ($camera.schema -ne 'fparkan-legacy-camera-v1' -or $camera.viewport.Count -ne 4) { + throw 'Expected a legacy camera JSON with a four-value viewport.' +} +$viewport = @($camera.viewport) +foreach ($value in $viewport) { + if ($value -lt 0 -or $value -gt [int]::MaxValue -or [Math]::Truncate($value) -ne $value) { + throw 'Viewport coordinates must be nonnegative integers.' + } +} +$crop = [System.Drawing.Rectangle]::new($viewport[0], $viewport[1], $viewport[2] - $viewport[0], $viewport[3] - $viewport[1]) +$native = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $NativePng).Path) +$viewer = $nativeCrop = $pair = $graphics = $null +try { + $viewer = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $ViewerPng).Path) + if ($crop.Width -le 0 -or $crop.Height -le 0 -or $crop.Right -gt $native.Width -or $crop.Bottom -gt $native.Height -or + $viewer.Width -ne $crop.Width -or $viewer.Height -ne $crop.Height) { + throw 'Native crop and viewer dimensions must match without scaling.' + } + $nativeCrop = $native.Clone($crop, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $pair = [System.Drawing.Bitmap]::new($viewer.Width * 2, $viewer.Height + 24) + $graphics = [System.Drawing.Graphics]::FromImage($pair) + $graphics.Clear([System.Drawing.Color]::Black) + $graphics.DrawImageUnscaled($nativeCrop, 0, 24) + $graphics.DrawImageUnscaled($viewer, $viewer.Width, 24) + $font = [System.Drawing.SystemFonts]::DefaultFont + $graphics.DrawString('Original', $font, [System.Drawing.Brushes]::White, 4, 4) + $graphics.DrawString('FParkan', $font, [System.Drawing.Brushes]::White, $viewer.Width + 4, 4) + $regions = @(Get-Content -LiteralPath $RegionsJson -Raw | ConvertFrom-Json) + if ($regions.Count -eq 0) { throw 'At least one named region is required.' } + $report = foreach ($region in $regions) { + if ([string]::IsNullOrWhiteSpace($region.name) -or $region.rect.Count -ne 4) { throw 'Each region needs name and rect [x,y,width,height].' } + foreach ($value in $region.rect) { + if ($value -lt 0 -or $value -gt [int]::MaxValue -or [Math]::Truncate($value) -ne $value) { throw 'Region coordinates must be nonnegative integers.' } + } + $rect = [System.Drawing.Rectangle]::new($region.rect[0], $region.rect[1], $region.rect[2], $region.rect[3]) + $originalMean = Get-MeanRgb $nativeCrop $rect + $viewerMean = Get-MeanRgb $viewer $rect + foreach ($offset in @(0, $viewer.Width)) { + $graphics.DrawRectangle([System.Drawing.Pens]::Yellow, $rect.X + $offset, $rect.Y + 24, $rect.Width, $rect.Height) + $graphics.DrawString($region.name, $font, [System.Drawing.Brushes]::Yellow, $rect.X + $offset, $rect.Y + 24) + } + [pscustomobject]@{ name = $region.name; rect = $region.rect; original_rgb = $originalMean; fparkan_rgb = $viewerMean } + } + $pair.Save($pngPath, [System.Drawing.Imaging.ImageFormat]::Png) + [pscustomobject]@{ native = $NativePng; viewer = $ViewerPng; camera = $CameraJson; rgb_encoding = 'stored image bytes, 0..255'; regions = @($report) } | + ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding UTF8 + Write-Output "Wrote $pngPath and $jsonPath" +} finally { + foreach ($resource in @($graphics, $pair, $nativeCrop, $viewer, $native)) { + if ($null -ne $resource) { $resource.Dispose() } + } +} |
