aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock1
-rw-r--r--README.md38
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert9
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert.spvbin20096 -> 20196 bytes
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs69
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs69
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs33
-rw-r--r--apps/fparkan-game/Cargo.toml4
-rw-r--r--apps/fparkan-game/src/main.rs10493
-rw-r--r--crates/fparkan-assets/src/lib.rs221
-rw-r--r--crates/fparkan-fx/src/environment.rs74
-rw-r--r--crates/fparkan-render/src/lib.rs127
-rw-r--r--crates/fparkan-runtime/Cargo.toml1
-rw-r--r--crates/fparkan-runtime/src/lib.rs1539
-rw-r--r--docs/appendices/knowledge-boundaries.md93
-rw-r--r--docs/appendices/script-vm.md103
-rw-r--r--docs/reference/environment-effects.md155
-rw-r--r--docs/reference/msh.md45
-rw-r--r--docs/reference/render-frame.md138
-rw-r--r--docs/tomes/05-render.md185
-rw-r--r--docs/tomes/06-behavior.md24
-rw-r--r--tools/native-frame-capture/NativeFrameCapture.cs67
-rw-r--r--tools/native-frame-capture/README.md41
-rw-r--r--tools/native-frame-capture/compare-frames.ps1106
24 files changed, 10964 insertions, 2671 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 354cd64..ff96caa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -639,6 +639,7 @@ version = "0.1.0"
dependencies = [
"fparkan-animation",
"fparkan-assets",
+ "fparkan-fx",
"fparkan-msh",
"fparkan-path",
"fparkan-platform",
diff --git a/README.md b/README.md
index d67cc98..663d4cd 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,10 @@
интерактивную сцену выбранной карты: terrain, все доступные MSH-компоненты
объектов, материалы, динамические тени, небо, погоду, lightning FX и звуковые
события. Это просмотр карты с полной графической сценой; прохождение миссии и
-игровое поведение юнитов пока не запускаются, а модели показываются в статической
-позе, заданной сохранёнными настройками узлов прототипа.
+игровое поведение юнитов пока не запускаются. Модели начинают с настроек узлов
+прототипа, а поддерживаемые Control-анимации и эффекты обновляются по общим
+часам сцены. Неподдерживаемые Control-треки остаются на сохранённой начальной
+позе.
## Запуск
@@ -46,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/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert
index d1e2e04..078a0d1 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert
@@ -130,12 +130,13 @@ float native_specular_power(float cosine, float power) {
}
float native_fog_factor(vec3 world_position) {
- float fog_span = frame.fog_distances.y - frame.fog_distances.x;
- float distance_to_camera = distance(world_position, frame.camera_position.xyz);
- if (fog_span <= 0.000001 || distance_to_camera <= frame.fog_distances.x) {
+ float fog_span_squared = frame.fog_distances.w - frame.fog_distances.z;
+ vec3 to_camera = world_position - frame.camera_position.xyz;
+ float distance_squared = dot(to_camera, to_camera);
+ if (fog_span_squared <= 0.000001 || distance_squared <= frame.fog_distances.z) {
return 0.0;
}
- return clamp((distance_to_camera - frame.fog_distances.x) / fog_span, 0.0, 1.0);
+ return clamp((distance_squared - frame.fog_distances.z) / fog_span_squared, 0.0, 1.0);
}
vec3 normalized_or(vec3 value, vec3 fallback) {
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
index f74b1f8..4fc9a1b 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
Binary files differ
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
index 522f95f..0e1cebb 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
@@ -2577,7 +2577,9 @@ mod tests {
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, LegacyDepthMode, LegacyPipelineState};
+ use fparkan_render::{
+ LegacyBlendMode, LegacyCullMode, LegacyDepthBias, LegacyDepthMode, LegacyPipelineState,
+ };
use std::cell::RefCell;
use std::rc::Rc;
@@ -3145,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");
@@ -3162,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),
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
index f004cb2..0996fa1 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
@@ -284,6 +284,9 @@ impl VulkanFrameUniforms {
.chain([self.fog_start, self.fog_end])
.chain(self.camera_position)
.all(f32::is_finite)
+ && (self.fog_end <= self.fog_start
+ || ((self.fog_start * self.fog_start).is_finite()
+ && (self.fog_end * self.fog_end).is_finite()))
}
/// Serializes one frame slot in the exact `std140` member order.
@@ -344,7 +347,17 @@ impl VulkanFrameUniforms {
self.fog_color[2],
0.0,
]);
- values[84..88].copy_from_slice(&[self.fog_start, self.fog_end, 0.0, 0.0]);
+ let squared_fog_range = if self.fog_end > self.fog_start {
+ [self.fog_start * self.fog_start, self.fog_end * self.fog_end]
+ } else {
+ [0.0; 2]
+ };
+ values[84..88].copy_from_slice(&[
+ self.fog_start,
+ self.fog_end,
+ squared_fog_range[0],
+ squared_fog_range[1],
+ ]);
values[88..92].copy_from_slice(&[
self.camera_position[0],
self.camera_position[1],
@@ -756,8 +769,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,
}],
}
@@ -875,6 +888,31 @@ mod static_mesh_tests {
}
#[test]
+ fn fog_uniforms_match_live_native_squared_distance_range() {
+ // Terrain ShadeIndexedStrided: far=0x442389e6, far²=0x48d0f1ca;
+ // distance²=0x47707b04 gives visibility=0x3f5b2b94.
+ let frame = VulkanFrameUniforms {
+ fog_start: 0.0,
+ fog_end: f32::from_bits(0x4423_89e6),
+ ..Default::default()
+ };
+ assert!(frame.is_finite());
+ let bytes = frame.to_ne_bytes();
+ let read = |offset| f32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
+ assert_eq!(read(344), 0.0);
+ assert_eq!(read(348).to_bits(), 0x48d0_f1ca);
+ let visibility = 1.0 - (f32::from_bits(0x4770_7b04) - read(344)) / (read(348) - read(344));
+ assert!((visibility - f32::from_bits(0x3f5b_2b94)).abs() < 1.0e-6);
+ assert!(VulkanFrameUniforms::default().is_finite());
+ assert!(!VulkanFrameUniforms {
+ fog_start: 0.0,
+ fog_end: f32::MAX,
+ ..frame
+ }
+ .is_finite());
+ }
+
+ #[test]
fn static_camera_composes_recovered_d3d7_view_before_projection() {
let transform = RawCameraTransform {
words: [
@@ -992,8 +1030,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 +1061,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 +1071,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 +1139,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 +1152,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 +1190,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 +1199,7 @@ mod static_mesh_tests {
alpha_test: true,
..LegacyPipelineState::default()
},
+ terrain_base_only: false,
alpha_test_reference: 128,
..base
};
@@ -1167,6 +1210,7 @@ mod static_mesh_tests {
alpha_test: true,
..LegacyPipelineState::default()
},
+ terrain_base_only: false,
alpha_test_reference: 0,
..base
};
@@ -1176,6 +1220,7 @@ mod static_mesh_tests {
alpha_test: true,
..LegacyPipelineState::default()
},
+ terrain_base_only: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
..base
};
@@ -1472,8 +1517,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 05ce927..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;
@@ -935,6 +936,14 @@ pub(super) fn draw_viewport(extent: (u32, u32), material_mode: f32) -> vk::Viewp
}
}
+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(
device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass,
@@ -1053,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)
@@ -1064,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);
@@ -1337,7 +1351,20 @@ fn destroy_partial_swapchain_resources(
#[cfg(test)]
mod tests {
- use super::draw_viewport;
+ 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() {
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 0dff5ea..279fa2c 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, PreparedModel, 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,
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, query_building_segment_world, BuildingMeshNode,
- EngineServices, MissionAssets, MissionMode, MissionObjectDraft, MissionRequest,
+ 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,
@@ -353,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();
@@ -622,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 {
@@ -631,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,
@@ -720,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)
@@ -745,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>,
@@ -758,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(
@@ -777,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,
@@ -800,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>,
@@ -822,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()
@@ -839,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
@@ -851,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 {
@@ -860,6 +1132,7 @@ fn sample_material_animations(
pipeline_category,
});
}
+ variant.pipeline_category = pipeline_category;
if !updated_materials.insert(material_index) {
continue;
}
@@ -871,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 {
(
@@ -883,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| {
@@ -942,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,
@@ -1053,11 +1335,29 @@ fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) -
diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0
}
-fn native_msh_diffuse_alpha_override(batch_flags: u16, type16_present: bool) -> Option<f32> {
- if type16_present || batch_flags & 0x0008 == 0 {
- return None;
+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);
}
- (batch_flags & 0x0040 != 0 && batch_flags & 0x0010 == 0).then_some(0.0)
+ 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 {
@@ -1370,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)
}
@@ -1511,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,
@@ -1532,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> {
@@ -1648,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> {
@@ -1721,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)
}
@@ -1870,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 {
@@ -1931,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
}
@@ -2184,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
@@ -2283,6 +2925,8 @@ impl EnvironmentGpuScene {
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>,
@@ -2717,6 +3361,7 @@ impl EnvironmentGpuScene {
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)
@@ -2738,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)
@@ -3278,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,
@@ -3508,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,
@@ -3532,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() {
@@ -3592,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,
@@ -3624,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
@@ -3942,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]))
}
@@ -3985,19 +4616,19 @@ struct StaticPreviewScene {
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>,
- building_collision_bindings: Vec<BuildingCollisionBinding>,
- dynamic_component_mounts: Vec<DynamicComponentMount>,
- shadow_caster_indices_by_object: Vec<Option<usize>>,
camera: VulkanStaticCamera,
view_camera: PreviewCameraFrame,
captured_viewport: Option<CapturedViewport>,
@@ -4007,25 +4638,24 @@ 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 {
- object_index: usize,
- component_index: usize,
- model: Arc<ModelAsset>,
- transform: LegacyIron3dEulerTransform,
- scale: [f32; 3],
+ model: ModelAsset,
+ root_transform: LegacyIron3dEulerTransform,
+ owner_scale: [f32; 3],
+ parent_mount: Option<(usize, usize)>,
vertex_start: usize,
vertex_count: usize,
- shadow_triangle_range: Option<std::ops::Range<usize>>,
}
#[derive(Clone)]
@@ -4230,12 +4860,23 @@ fn component_node_world_matrix(
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)]
@@ -4326,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]);
@@ -4443,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 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 component_chain_has_tag21(
- tree: Option<&[UnitComponentTreeNode]>,
- controls: &[Option<PreparedComponentControl>],
+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,
})
}
@@ -4625,22 +7926,55 @@ fn static_preview_mesh_and_materials(
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 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 shadow_receivers = terrain_shadow_receivers(terrain_mesh)?;
+ 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,
@@ -4705,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));
@@ -4716,61 +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,
- });
- }
+ 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.
@@ -4862,30 +8208,31 @@ 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 {
@@ -4967,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 {
@@ -4989,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(
@@ -5000,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,
@@ -5017,54 +8365,90 @@ fn static_preview_mesh_and_materials(
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());
@@ -5093,19 +8477,19 @@ fn static_preview_mesh_and_materials(
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,
- building_collision_bindings,
- dynamic_component_mounts,
- shadow_caster_indices_by_object,
+ terrain: Arc::new(terrain_query),
camera,
view_camera,
captured_viewport,
@@ -5331,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,
@@ -5481,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(),
@@ -5598,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
@@ -5630,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");
@@ -5669,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()
})?;
@@ -5676,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());
@@ -5782,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}"))?;
@@ -5801,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) => {
@@ -5815,6 +9218,8 @@ fn run_static_vulkan_mode(
&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,
@@ -5836,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) {
@@ -5849,98 +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_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>,
building_collision_bindings: Vec<BuildingCollisionBinding>,
- dynamic_component_mounts: Vec<DynamicComponentMount>,
- shadow_caster_indices_by_object: Vec<Option<usize>>,
- shadow_sphere_baselines: Vec<ShadowSphere>,
+ 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,
@@ -5957,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,
@@ -5972,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>,
}
@@ -5992,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
@@ -6021,16 +9365,17 @@ impl StaticVulkanApp {
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,
building_collision_bindings: preview.building_collision_bindings,
- 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,
+ 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,
@@ -6047,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,
@@ -6062,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,
}
@@ -6076,7 +9427,34 @@ 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)");
+ let randomized_lights = self
+ .control_point_light_slots
+ .iter()
+ .filter(|slot| {
+ [92..108, 116..120].into_iter().any(|range| {
+ slot.command.raw_body.get(range).is_some_and(|bytes| {
+ bytes
+ .chunks_exact(4)
+ .any(|word| f32::from_le_bytes(word.try_into().unwrap()) != 0.0)
+ })
+ })
+ })
+ .count();
+ eprintln!("render_coverage control_op1_jitter_unsupported={randomized_lights} (using authored color and radius; native RNG sampling pending)");
+ eprintln!("render_coverage fog_pass_eligibility_approx={} (global world fog and horizon RGB; native shade-state/color verification pending)", self.world_range_indices.len());
for &range_index in &self.world_range_indices {
let range = self
.mesh
@@ -6088,9 +9466,12 @@ impl StaticVulkanApp {
.get(usize::from(range.material_index))
.map_or(1.0, |material| material.diffuse_alpha);
let alpha_override = native_msh_diffuse_alpha_override(
- range.batch_flags,
+ &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)
@@ -6130,6 +9511,49 @@ 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],
@@ -6138,6 +9562,7 @@ impl StaticVulkanApp {
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> {
@@ -6156,9 +9581,12 @@ impl StaticVulkanApp {
|state| state.diffuse_alpha,
);
let alpha_override = native_msh_diffuse_alpha_override(
- range.batch_flags,
+ 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)
@@ -6207,23 +9635,10 @@ impl StaticVulkanApp {
free_camera.position = safe_position;
}
let view_camera = free_camera.preview_frame(aspect);
- self.free_camera = Some(free_camera);
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(())
}
@@ -6259,156 +9674,55 @@ impl StaticVulkanApp {
Ok(false)
}
- fn update_material_clock(&mut self, sample: FrameClockSample) {
- self.material_time_ms = sample.time_ms;
+ 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,
+ ),
+ };
+ }
+ self.sample_live_frame_clock_at(Instant::now())
}
- 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;
- }
- }
- if !pose_changed {
- return Ok(());
+ 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(),
}
+ }
- // 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
- .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
- .component_mount_poses
- .get(dynamic.object_index)
- .and_then(|poses| poses.get(dynamic.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(|| {
- format!(
- "animated component {} vertex range is outside the shared mesh",
- dynamic.component_index
- )
- })?;
- 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));
- }
- }
- 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;
- };
- 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);
+ 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(),
}
- 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}")
- })?;
- }
- }
+ }
+
+ 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(
@@ -6420,12 +9734,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,
@@ -6449,9 +9765,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}"))?;
@@ -6461,88 +9780,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,
@@ -6552,17 +10285,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,
@@ -6572,149 +10309,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.type16_present_ranges,
- &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_material_clock(sample);
- self.update_controlled_component_geometry(sample)?;
- self.update_free_camera(sample.delta_seconds, viewport)?;
- 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) {
@@ -6805,6 +10415,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| {
@@ -6815,14 +10432,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,
@@ -6832,6 +10452,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),
@@ -6948,7 +10571,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;
@@ -6972,13 +10603,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();
}
@@ -7049,25 +10695,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());
@@ -7078,28 +10726,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) => {
@@ -7108,11 +10778,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();
}
}
_ => {}
@@ -7128,28 +10795,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();
}
@@ -7164,6 +10824,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>,
@@ -7182,6 +10843,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
@@ -7245,6 +10907,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()
@@ -7289,6 +10957,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,
@@ -7297,6 +10971,7 @@ impl Args {
control_clock_ms,
validation,
readback_out,
+ readback_sequence_out,
preview_roots,
legacy_camera_capture,
static_animation_frame,
@@ -7463,207 +11138,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(&current_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,
type16_present: false,
- indices: vec![],
- batches: vec![],
+ 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],
},
@@ -7675,101 +11510,54 @@ 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 control_pose_test_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()),
- type_id: None,
- },
- model_node_count: 1,
- native_profile_words: [0; 27],
- bindings: vec![],
- timeline_rows: vec![fparkan_assets::PreparedControlTimelineRow {
- row_index: 0,
- node_index: 0,
- frame_a: 0.0,
- frame_b: 1.0,
- initial_blend: 0.0,
- raw_parameters: [u32::MAX, u32::MAX, 1.0_f32.to_bits(), 0],
- flags: 0,
+ 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],
}],
- typed_records: vec![fparkan_assets::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()),
- type_id: None,
- },
- row_count: 1,
- initial_type1_value: 1.0,
- },
+ 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,
}],
- node_data: None,
- effect_section: fparkan_assets::PreparedControlEffectSection::Absent,
- initial_effect_group: None,
- point_list: None,
- }
- }
-
- 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());
+ 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,
+ }),
}
- node
}
fn collision_fixture_model(root_drawable: bool) -> ModelAsset {
@@ -7781,7 +11569,7 @@ mod tests {
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 3.0,
- slots: vec![fparkan_msh::Slot {
+ slots: vec![Slot {
tri_start: 0,
tri_count: 1,
batch_start: 0,
@@ -7798,7 +11586,7 @@ mod tests {
uv1: None,
type16_present: false,
indices: vec![0, 1, 2],
- batches: vec![fparkan_msh::Batch {
+ batches: vec![Batch {
batch_flags: 0,
material_index_hi: 0,
material_index: 0,
@@ -7815,15 +11603,15 @@ mod tests {
normal: [0, 32767, 0],
}]),
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: [20.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
@@ -7835,6 +11623,78 @@ mod tests {
}
}
+ #[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));
@@ -7844,7 +11704,7 @@ mod tests {
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 5.0,
- slots: vec![fparkan_msh::Slot {
+ slots: vec![Slot {
tri_start: 0,
tri_count: 4,
batch_start: 0,
@@ -7870,7 +11730,7 @@ mod tests {
uv1: None,
type16_present: false,
indices: vec![0, 3, 2, 0, 2, 1, 4, 7, 6, 4, 6, 5],
- batches: vec![fparkan_msh::Batch {
+ batches: vec![Batch {
batch_flags: 0,
material_index_hi: 0,
material_index: 0,
@@ -7891,10 +11751,10 @@ mod tests {
.collect(),
),
node_names: None,
- animation: Some(fparkan_msh::ModelAnimation {
- keys: vec![fparkan_animation::AnimKey24 {
+ animation: Some(ModelAnimation {
+ keys: vec![AnimKey24 {
time: AnimationTime(0.0),
- pose: fparkan_animation::Pose::default(),
+ pose: Pose::default(),
}],
frame_map: Vec::new(),
frame_count: 0,
@@ -7902,47 +11762,30 @@ mod tests {
}
}
- fn collision_test_terrain() -> Arc<TerrainWorld> {
- 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![
- [-100.0, -100.0, 0.0],
- [100.0, -100.0, 0.0],
- [0.0, 100.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);
- Arc::new(TerrainWorld::from_land_msh(&land).expect("collision test terrain"))
- }
-
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");
- let camera = FreeFlightCamera {
+ 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,
@@ -7951,143 +11794,12 @@ mod tests {
far_plane: 700.0,
move_speed: 60.0,
ground_clearance: 0.0,
- };
- let view_camera = camera.preview_frame(16.0 / 9.0);
- let preview = StaticPreviewScene {
- mesh: VulkanStaticMesh {
- vertices: Vec::new(),
- indices: Vec::new(),
- draw_ranges: Vec::new(),
- },
- native_handedness_by_range: HashMap::new(),
- type16_present_ranges: HashSet::new(),
- materials: Vec::new(),
- material_animations: Vec::new(),
- sun_occlusion_range_indices: Vec::new(),
- shadow_casters: Vec::new(),
- shadow_sphere_baselines: Vec::new(),
- shadow_receivers: Vec::new(),
- shadow_material_index: 0,
- terrain: collision_test_terrain(),
- component_pose_buffers: vec![vec![Some(hierarchy)]],
- component_mount_poses: vec![vec![None]],
- control_animation_instances: Vec::new(),
- dynamic_component_geometry: Vec::new(),
- 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")],
- dynamic_component_mounts: Vec::new(),
- shadow_caster_indices_by_object: vec![None],
- camera: view_camera.vulkan_camera(),
- view_camera,
- captured_viewport: None,
- free_camera: Some(camera),
- camera_mode: "test",
- mesh_components: 0,
- terrain_components: 0,
- };
- let mut app = StaticVulkanApp::new(
- preview,
- None,
- None,
- EnvironmentGpuRange {
- range_index: usize::MAX,
- vertex_start: 0,
- vertex_capacity: 0,
- material_index: 0,
- },
- 0,
- 0,
- None,
- 0,
- None,
- "collision-test",
- 1,
- None,
- false,
- StartupTimings::default(),
- );
+ });
app.pressed_keys.insert(KeyCode::KeyW);
app
}
#[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 collision binding");
- assert_eq!(binding.selected_nodes, vec![(NodeId(1), SlotId(0))]);
-
- let root = VulkanNodePose {
- translation: [1.0, 2.0, 3.0],
- rotation: [0.0, 0.0, 0.29552022, 0.9553365],
- };
- let child = 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, child]
- .into_iter()
- .map(|pose| fparkan_animation::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, 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);
- }
- }
- }
-
- #[test]
fn high_speed_free_flight_stops_at_static_building_wall() {
let mut app = collision_test_app(
Arc::new(collision_fixture_model(false)),
@@ -8179,27 +11891,25 @@ mod tests {
.expect("second placed building");
assert!(Arc::ptr_eq(&far.model, &near.model));
- let mut app = collision_test_app(
- Arc::clone(&model),
- LegacyIron3dEulerTransform {
- translation: [0.0, 100.0, 0.0],
- orientation_radians: [0.0; 3],
- },
- [0.0, 8.0, 0.0],
- );
+ let (mut app, _) = clock_test_app(None);
let hierarchy = static_preview_component_pose_buffer(&model, &[], None)
- .expect("fixture static pose")
- .expect("fixture Node38 pose");
+ .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_pending_retry_does_not_move_again() {
+ 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),
@@ -8209,54 +11919,39 @@ mod tests {
},
[0.0, 8.0, 0.0],
);
- let mut control = control_pose_test_control();
- control.model_node_count = 2;
+ let mut control = socket_timeline_control();
control.timeline_rows[0].node_index = 1;
- if let PreparedControlGateProvider::Resolved {
- initial_type1_value,
- ..
- } = &mut control.typed_records[0].gate_provider
- {
- *initial_type1_value = 0.0;
- } else {
- panic!("fixture has a resolved NDP gate");
- }
- app.control_animation_instances = vec![ControlAnimationInstance {
- object_index: 0,
- component_index: 0,
- model: Arc::clone(&model),
+ 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,
};
- let update = app
- .prepare_scene_update(first, [1280.0, 720.0])
+ app.prepare_scene_update(first, [1280.0, 720.0])
.expect("closed moving wall blocks the first frame");
- assert!(app.lifecycle.prepare(update));
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]);
- if let PreparedControlGateProvider::Resolved {
- initial_type1_value,
- ..
- } = &mut app.control_animation_instances[0].control.typed_records[0].gate_provider
- {
- *initial_type1_value = 1.0;
- }
- app.refresh_pending_viewport([1024.0, 768.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
);
- assert_eq!(app.lifecycle.record_presented(), Some(0));
app.prepare_scene_update(
FrameClockSample {
@@ -8274,14 +11969,7 @@ mod tests {
#[test]
fn missing_free_camera_keeps_legacy_view_without_querying_buildings() {
- 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],
- );
+ let (mut app, _) = clock_test_app(None);
app.free_camera = None;
app.building_collision_bindings
.push(BuildingCollisionBinding {
@@ -8306,570 +11994,10 @@ mod tests {
}
#[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();
- 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(),
- )
- .expect_err("control/mesh node-count mismatch is rejected");
- assert!(mismatch.contains("does not match MSH node count"));
- }
-
- #[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 {
- archive_raw: [0; 32],
- resource_raw: [0; 32],
- kind: 0,
- 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 empty_parent_mesh = project_dynamic_component_geometry(
- &parent_geometry,
- pose_buffers[0][1].as_ref().expect("EXTO parent hierarchy"),
- None,
- )
- .expect("zero-geometry EXTO parent");
- assert!(empty_parent_mesh.vertices.is_empty());
- assert!(empty_parent_mesh.indices.is_empty());
-
- 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]
- );
- // The EXTO parent has a sampled Node38 hierarchy but no projected draw
- // vertices. Its hierarchy still drives the visible child's mount.
- }
-
- #[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 {
- 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 view_camera = free_camera.preview_frame(16.0 / 9.0);
- let preview = StaticPreviewScene {
- mesh: initial_mesh.clone(),
- native_handedness_by_range: HashMap::new(),
- type16_present_ranges: HashSet::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],
- building_collision_bindings: Vec::new(),
- 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
- );
- 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
- );
- 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;
- }
- 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);
-
- let first_sample = FrameClockSample {
- id: SceneSampleId::Live(0),
- time_ms: 0,
- delta_seconds: 0.0,
- };
- 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());
- }
-
- #[test]
#[ignore = "requires licensed corpus"]
fn licensed_plant_wall_and_door_use_the_app_camera_bridge() {
use std::path::PathBuf;
- fn set_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);
- }
-
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(
@@ -8927,11 +12055,12 @@ mod tests {
0,
None,
2,
- None,
mission,
loaded.object_count,
None,
+ None,
false,
+ None,
StartupTimings::default(),
);
let plant = app
@@ -8972,7 +12101,6 @@ mod tests {
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()
@@ -9024,48 +12152,44 @@ mod tests {
assert_eq!(constrained.position, point);
}
- set_segment(&mut app, world_wall);
+ set_collision_test_segment(&mut app, world_wall);
let zero_delta = FrameClockSample {
id: SceneSampleId::Replay(0),
time_ms: 0,
delta_seconds: 0.0,
};
- let update = app
- .prepare_scene_update(zero_delta, [1280.0, 720.0])
+ app.prepare_scene_update(zero_delta, [1280.0, 720.0])
.expect("zero-delta resume initialization");
- assert!(app.lifecycle.prepare(update));
assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
- assert_eq!(app.lifecycle.record_presented(), Some(0));
+ assert_eq!(app.record_presented_scene_frame(), 0);
let wall_frame = FrameClockSample {
id: SceneSampleId::Replay(1),
time_ms: 0,
delta_seconds: 0.1,
};
- let update = app
- .prepare_scene_update(wall_frame, [1280.0, 720.0])
+ app.prepare_scene_update(wall_frame, [1280.0, 720.0])
.expect("high-speed wall movement through the app update path");
- assert!(app.lifecycle.prepare(update));
assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
app.pressed_keys.clear();
app.pressed_keys.insert(KeyCode::KeyD);
- app.refresh_pending_viewport([1024.0, 768.0])
+ 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.lifecycle.record_presented(), Some(1));
+ assert_eq!(app.record_presented_scene_frame(), 1);
- set_segment(&mut app, world_door);
- let closed_frame = FrameClockSample {
- id: SceneSampleId::Replay(2),
- time_ms: 0,
- delta_seconds: 0.1,
- };
- let update = app
- .prepare_scene_update(closed_frame, [1024.0, 768.0])
- .expect("the closed Node3 door blocks through the app update path");
- assert!(app.lifecycle.prepare(update));
+ 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.lifecycle.record_presented(), Some(2));
+ assert_eq!(app.record_presented_scene_frame(), 2);
let control = assets
.object_component_controls
@@ -9096,16 +12220,16 @@ mod tests {
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));
- app.component_pose_buffers[4][0] = Some(
- node38_sampled_hierarchy_at_times(&plant.model, &open_node_times)
- .expect("sample the actual PLANT model at its authored Node3 frame B"),
- );
- refresh_dynamic_component_mounts(
- &app.dynamic_component_mounts,
+ 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("refresh mounts from the authored open-door hierarchy");
+ .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");
@@ -9122,7 +12246,7 @@ mod tests {
"the authored Node3 time-1 pose leaves the doorway ray clear"
);
- set_segment(&mut app, world_door);
+ 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;
@@ -9131,48 +12255,682 @@ mod tests {
}
}
- 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 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 strings(values: &[&str]) -> Vec<String> {
- values.iter().map(|value| (*value).to_string()).collect()
+ 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();
+ PreparedComponentControl {
+ source: ResourceKey {
+ archive: normalize_relative(b"test.rlb", PathPolicy::HostCompatible)
+ .expect("archive path"),
+ name: resource_name(b"animated.ctl"),
+ type_id: None,
+ },
+ native_profile_words: [0; 27],
+ model_node_count: 2,
+ bindings: vec![],
+ timeline_rows: vec![PreparedControlTimelineRow {
+ row_index: 0,
+ node_index: 0,
+ frame_a: 0.0,
+ frame_b: 1.0,
+ initial_blend: 0.0,
+ raw_parameters: [u32::MAX, u32::MAX, 1.0_f32.to_bits(), 0],
+ flags: 0,
+ }],
+ typed_records: vec![PreparedControlTypedRecord {
+ tag: 21,
+ fixed_words,
+ values: vec![0],
+ string_bytes: vec![],
+ gate_provider: PreparedControlGateProvider::Resolved {
+ key: 0,
+ 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,
+ initial_type1_value: 1.0,
+ },
+ }],
+ node_data: None,
+ effect_section: PreparedControlEffectSection::Absent,
+ initial_effect_group: None,
+ point_list: None,
+ }
}
- 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,
+ 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],
}
- .preview_frame(aspect);
- SceneFrameUpdate {
- sample,
- camera,
- viewport,
- aspect,
- material_update: MaterialFrameUpdate::default(),
- environment_material_update: None,
- environment: None,
+ }
+
+ 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 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 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("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 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,
+ parent_or_link: 1,
+ description_raw: [0; 32],
+ tail0: 0,
+ immediate_child_count,
+ };
+ 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 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("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)));
+
+ 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
+ );
+ assert_eq!(mesh.vertices[0].position, later_child_mount.translation);
+ }
+
+ #[test]
+ fn dynamic_shadow_sphere_union_keeps_the_containing_input_sphere() {
+ let small = ShadowSphere {
+ center: [0.0; 3],
+ radius: 1.0,
+ };
+ let large = ShadowSphere {
+ center: [1.0, 0.0, 0.0],
+ radius: 10.0,
+ };
+
+ assert_eq!(
+ shadow_sphere_enclosing_current_geometry(small, large, &[])
+ .expect("large current native sphere contains baseline"),
+ large
+ );
+ assert_eq!(
+ shadow_sphere_enclosing_current_geometry(large, small, &[])
+ .expect("large baseline sphere contains current native sphere"),
+ large
+ );
+ }
+
+ 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(),
+ }
+ }
+
+ 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(),
+ }
+ }
+
+ 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"
+ };
+ 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 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 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 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() {
@@ -9323,9 +13081,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,
@@ -9382,14 +13143,34 @@ mod tests {
}
#[test]
- fn native_cpu_shaded_msh_alpha_override_uses_only_the_constant_batch_rule() {
- assert_eq!(native_msh_diffuse_alpha_override(0x0148, false), Some(0.0));
- assert_eq!(native_msh_diffuse_alpha_override(0x0108, false), None);
- assert_eq!(native_msh_diffuse_alpha_override(0x0118, false), None);
- assert_eq!(native_msh_diffuse_alpha_override(0x0058, false), None);
- assert_eq!(native_msh_diffuse_alpha_override(0x0148, true), None);
- assert_eq!(native_msh_diffuse_alpha_override(0x0058, true), None);
- assert_eq!(native_msh_diffuse_alpha_override(0x0040, false), None);
+ 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]
@@ -9397,6 +13178,7 @@ mod tests {
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),
@@ -9471,36 +13253,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]
@@ -9573,6 +13352,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,
@@ -9597,44 +13377,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 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 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 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",
@@ -9645,8 +14191,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]
@@ -9661,9 +14217,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,
@@ -9684,6 +14243,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];
@@ -9940,7 +14500,7 @@ mod tests {
}
#[test]
- fn captured_native_camera_uses_view_dependent_sky_fog_color() {
+ fn captured_native_camera_selects_view_dependent_screen_horizon_color() {
use fparkan_fx::atmosphere::{PackedColor, SkySample, SKY_COLOR_COUNT};
// Compact camera fields from the native 1493.337-second AutoDemo capture.
@@ -9962,12 +14522,12 @@ mod tests {
values: [0.0; 2],
packed: [PackedColor(0); 2],
};
- let fog_color =
+ let horizon_color =
fparkan_fx::sky::screen_gradient_frame(&sample, camera.native_sky_heading(), [0.0; 3])
.color;
assert_eq!(capture.atmosphere_seconds, Some(1493.337));
- assert_eq!(fog_color, PackedColor(0xFF3E_3E64));
+ assert_eq!(horizon_color, PackedColor(0xFF3E_3E64));
}
#[test]
@@ -9975,6 +14535,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 {
@@ -10016,14 +14892,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 =
@@ -10099,7 +14970,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(
@@ -10146,7 +15017,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));
@@ -10361,6 +15232,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",
@@ -10536,6 +15472,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");
@@ -10861,328 +15850,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 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, alpha_override: &mut Option<f32>| {
- *alpha_override = native_msh_diffuse_alpha_override(range.batch_flags, false);
- world_draw_range_index_count(
- &mesh,
- range,
- original_count,
- alpha_override.unwrap_or(1.0),
- [0.0; 3],
- None,
- )
+ 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,
};
- assert_eq!(update(&range, &mut alpha_override), 0);
- range.batch_flags = 0x0108;
- assert_eq!(update(&range, &mut alpha_override), original_count);
- range.batch_flags = 0x0148;
- assert_eq!(update(&range, &mut alpha_override), 0);
- assert_eq!(original_count, 3);
+ 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 9cc6b5b..a1ae4ec 100644
--- a/crates/fparkan-assets/src/lib.rs
+++ b/crates/fparkan-assets/src/lib.rs
@@ -2112,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.
@@ -3881,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>,
}
@@ -3905,6 +3933,7 @@ where
}
},
initial_effect_group: None,
+ initial_group_opcodes: (cursor == payload.len()).then(Vec::new),
typed_records: Vec::new(),
});
}
@@ -3926,6 +3955,7 @@ where
typed_record_tag: Some(tag),
},
initial_effect_group: None,
+ initial_group_opcodes: None,
typed_records,
});
}
@@ -4015,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 {
@@ -4025,6 +4056,7 @@ where
}
},
initial_effect_group: None,
+ initial_group_opcodes: complete.then(Vec::new),
typed_records,
});
}
@@ -4043,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(
@@ -4069,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 = [
@@ -4108,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,
})
}
@@ -4130,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;
@@ -4267,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> {
@@ -6804,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"),
diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs
index 68a2c8f..713a3f9 100644
--- a/crates/fparkan-fx/src/environment.rs
+++ b/crates/fparkan-fx/src/environment.rs
@@ -590,7 +590,7 @@ impl LightningBounds {
/// Effect.dll's opcode 1 handler (`1000F6E0`) updates a LightManager record.
/// The A and B groups are the submitted position and direction, C is the
/// four-component HDR light color, the scalar pair at body offsets 108 and
-/// 112 is the light range, and body offsets 120, 124, and 128 are the native
+/// 112 is the local light radius, and body offsets 120, 124, and 128 are the native
/// attenuation coefficients. These values do not describe lightning mesh
/// geometry or texture coordinates.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -615,8 +615,7 @@ pub struct PointLightFrame {
pub range: f32,
/// Native attenuation coefficients `(a0, a1, a2)`.
pub attenuation: [f32; 3],
- /// The scalar at body offset 116. Its native role is an effect factor,
- /// not alpha or a UV parameter, so it is preserved without reinterpretation.
+ /// Radius jitter amplitude at body offset 116; retained but not sampled yet.
pub effect_factor: f32,
/// Whether the opcode 1 command is active at `command_time`.
pub active: bool,
@@ -626,8 +625,8 @@ pub struct PointLightFrame {
/// applies a parent basis to its local position and direction. `basis` stores
/// the world-space vectors corresponding to local +X, +Y, and +Z.
/// `command_time` is resolved by the caller; FX header duration and lifecycle
-/// remain with that caller. The interpolated range is retained as authored;
-/// native Op1 does not clamp it to zero.
+/// remain with that caller. Native Op1 scales the radius by the transformed
+/// direction length and replaces a non-positive result with 0.01.
#[must_use]
pub fn evaluate_op1_point_light_with_basis(
command: &FxCommand,
@@ -677,7 +676,22 @@ pub fn evaluate_op1_point_light_with_basis(
{
return None;
}
- let direction = normalize_op1_direction(transformed_direction);
+ let direction_length = transformed_direction
+ .into_iter()
+ .map(|component| component * component)
+ .sum::<f32>()
+ .sqrt();
+ // shortcut: Op1 color/radius jitter is omitted; add native RNG sampling
+ // when the reported randomized Control lights are handled in phase R4.
+ let scaled_range = sample.range * direction_length;
+ if !direction_length.is_finite() || !scaled_range.is_finite() {
+ return None;
+ }
+ let direction = if direction_length > 0.0 {
+ transformed_direction.map(|component| component / direction_length)
+ } else {
+ [0.0; 3]
+ };
Some(PointLightFrame {
effect_type: sample.effect_type,
@@ -688,7 +702,11 @@ pub fn evaluate_op1_point_light_with_basis(
position,
direction,
color: sample.color,
- range: sample.range,
+ range: if scaled_range <= 0.0 {
+ 0.01
+ } else {
+ scaled_range
+ },
attenuation: sample.attenuation,
effect_factor: sample.effect_factor,
active: true,
@@ -722,19 +740,6 @@ fn sample_op1_point_light(body: &[u8], normalized_time: f32) -> Option<Op1PointL
})
}
-fn normalize_op1_direction(direction: [f32; 3]) -> [f32; 3] {
- // Native FastProc normalization preserves a degenerate vector as zero;
- // this path must not invent a fallback axis.
- let length =
- (direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2])
- .sqrt();
- if length.is_finite() && length > 0.0 {
- direction.map(|component| component / length)
- } else {
- [0.0; 3]
- }
-}
-
fn lightning_point_light_direction(local_direction: [f32; 3], basis: [[f32; 3]; 3]) -> [f32; 3] {
// Preserve the pre-existing Lightning consumer fallback, which is
// separate from the generic native Op1 behavior.
@@ -2964,7 +2969,7 @@ mod tests {
}
#[test]
- fn generic_opcode1_preserves_negative_native_range() {
+ fn generic_opcode1_clamps_non_positive_scaled_range() {
let mut command = op1_test_command(5, 0.0, 1.0);
let mut body = command.raw_body.to_vec();
body[108..112].copy_from_slice(&(-2.0_f32).to_le_bytes());
@@ -2973,7 +2978,7 @@ mod tests {
let sample = evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3])
.expect("native Op1 sample with a negative range");
- assert_eq!(sample.range, -3.0);
+ assert_eq!(sample.range, 0.01);
}
#[test]
@@ -2986,6 +2991,31 @@ mod tests {
let sample = evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3])
.expect("native Op1 sample with a zero direction");
assert_eq!(sample.direction, [0.0; 3]);
+ assert_eq!(sample.range, 0.01);
+ }
+
+ #[test]
+ fn generic_opcode1_scales_radius_like_live_factory_light() {
+ // Live Effect.dll Op1: authored radius 7, FastProc direction length
+ // 0x409b1f91, submitted LightManager radius 0x4207bb9f.
+ let mut command = op1_test_command(7, 0.75, 1.0);
+ let mut body = command.raw_body.to_vec();
+ body[48..60].copy_from_slice(&[1.0_f32, 0.0, 0.0].map(f32::to_le_bytes).concat());
+ body[112..116].copy_from_slice(&7.0_f32.to_le_bytes());
+ command.raw_body = Arc::from(body.into_boxed_slice());
+ let sample = evaluate_op1_point_light_with_basis(
+ &command,
+ 1.0,
+ [0.0; 3],
+ [
+ [f32::from_bits(0x409b_1f91), 0.0, 0.0],
+ [0.0, 1.0, 0.0],
+ [0.0, 0.0, 1.0],
+ ],
+ )
+ .expect("live factory radius");
+ assert_eq!(sample.range.to_bits(), 0x4207_bb9f);
+ assert_eq!(sample.direction, [1.0, 0.0, 0.0]);
}
#[test]
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 8eb3e84..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" }
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index c962649..5cc2d86 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -2,26 +2,28 @@
//! Runtime orchestration for mission loading and world stepping.
use fparkan_assets::ClanBody;
-#[cfg(test)]
-use fparkan_assets::PreparedControlResourcePair;
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, ResearchError,
- ResearchTrfDocument, 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::{
FormulaTable, Handler19InitInput, ScriptInitResolution, ScriptInitResolveError,
ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSet, VarSetDefault,
@@ -205,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
@@ -225,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`.
@@ -1722,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)
@@ -1736,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,
@@ -1743,6 +2017,7 @@ fn load_mission_with_options(
prototype_span,
&resolved_prototypes,
native_control_class,
+ &mut resolve_factory2_pair,
);
match projection {
Ok(projection) => MissionControlDeviceTableState {
@@ -1786,6 +2061,13 @@ 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()
@@ -2309,12 +2591,17 @@ pub fn query_loaded_mission_control_resource(
/// 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.
+/// 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, an unsupported float range, or an
-/// unsupported current/factory tag on selector `0xe00`.
+/// 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,
@@ -4416,7 +4703,7 @@ fn query_control_device_table_selector(
device_index: usize,
selector: u32,
) -> Result<Option<f32>, ControlResourceAccessError> {
- if !matches!(selector, 0x400 | 0xe00) {
+ if !matches!(selector, 6 | 0x400 | 0xe00) {
return Err(ControlResourceAccessError::UnsupportedSelector(selector));
}
let devices = match &table.status {
@@ -4436,6 +4723,17 @@ fn query_control_device_table_selector(
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)
}
@@ -5794,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,
@@ -5802,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,
@@ -5809,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(
@@ -5902,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,
@@ -5909,6 +6267,7 @@ fn build_control_device_projection(
prototype_span,
prototypes,
native_class,
+ &mut resolve_factory2_pair,
)
}
@@ -5919,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;
@@ -6227,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;
@@ -6267,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);
}
}
@@ -6295,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);
}
@@ -7014,8 +7393,983 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi
#[cfg(test)]
mod tests {
use super::*;
+ 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 {
@@ -7062,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>,
@@ -7426,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,
@@ -10942,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,
@@ -10949,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
@@ -14464,6 +15837,144 @@ mod tests {
}
#[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(
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index 7fb6798..7f049fc 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -10,6 +10,10 @@ authoring API без динамического подтверждения.
Не доказаны символами точные имена renderer vtable slots, полный набор CShade
state transitions и окончательный порядок части transparent/FX/shadow subpasses.
+Дистанционная кривая тумана по квадратам границ подтверждена в живом CShade.
+Выбор проходов по native shade-state и GPU fog color пока заменён общим туманом мира с цветом горизонта;
+это приближение считается как `render_coverage fog_pass_eligibility_approx`.
+
После ограничения цели рельефом интерактивная камера проверяет движение
против каждого отрисовываемого корневого строения класса `FORT` и его
отрисовываемых компонентов. Для этого пути используются текущая Node38-поза
@@ -45,26 +49,13 @@ MSH-запрос со свободной preview-камерой; это не пÐ
стратегической и следящей камерами не подтверждено
([MSH](../reference/msh.md)).
-Для MSH без потока цветов type 16 подтверждён только один постоянный alpha
-override для диапазонов с CPU-цветом и raw-флагом `Batch20` `0x0008`: GOG
-`Terrain.dll`, SHA-256
-`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`, RVA
-`0x1B0F0`, задаёт alpha `0`, если установлен bit `0x0040` и сброшен `0x0010`.
-Это покрывает `0x0148`; `0x0108`, `0x0118` и `0x0058` сохраняют alpha MAT0,
-причём в `0x0058` bit `0x0010` имеет приоритет. Поток type 16 или отсутствие
-`0x0008` исключают override. Проверка заводской сцены с фиксированной нативной
-камерой подтвердила на одном кадре входа восстановление X-образного рисунка
-двери, который виден и в оригинале; прежний Vulkan-readback показывал на этом
-месте сплошной чёрный прямоугольник. Сравнение выполнено при времени сцены
-2000 мс и трёх кадрах, запуск с Vulkan validation завершился без предупреждений
-и ошибок. Это подтверждает только структуру проёма в этой сцене: правильность
-освещения и полный попиксельный паритет не установлены.
-
-Общее fade-поведение по расстоянию в preview не реализовано. Открыты точная
-метрика расстояния, выбор первого CPU-принятого треугольника диапазона и
-эквивалентность frustum/culling-отбора оригинала; эти вопросы нельзя вывести из
-захвата заводской двери. Данные потока type 16 ещё не декодированы, поэтому
-правило выше относится только к MSH-диапазонам без этого потока цветов.
+Кривые 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
@@ -72,6 +63,10 @@ log на минимальных сценах с одним типом матер
## FXID field-level semantics
+Control Op1 пока использует цвет и радиус без случайных добавок команды;
+это явное приближение считается в `render_coverage control_op1_jitter_unsupported`.
+Масштаб радиуса длиной преобразованного направления и нижняя замена 0,01 восстановлены.
+
Размеры команд, resource references, lifecycle, flags families и используемые
time modes известны. Не закрыто значение каждого поля body opcodes 1--10,
отсутствующий во всех проверенных каталогах opcode 6 и точные формулы редких
@@ -536,6 +531,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` в текущее и
@@ -778,16 +808,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` не имеет доказанных единиц
diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md
index 51dd429..f7f8b97 100644
--- a/docs/appendices/script-vm.md
+++ b/docs/appendices/script-vm.md
@@ -1410,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`). Последний путь не является
@@ -1477,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`)
diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md
index 799a969..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,13 +258,17 @@ position = [
];
```
-Vertex shader пропускает `clip_from_world` для строк солнца и луны. CPU каждый
-кадр пересчитывает центр и размер quad по текущему viewport; старые пиксельные
-координаты не переносятся на новый размер. World-space billboards бликов
-остаются отдельным путём. Материал экранного quad не освещается, vertex fog
-отключён, а sky far-depth bit к нему не применяется.
+Положительная высота 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`: после отсечения
проекционная глубина отображается в этот диапазон:
@@ -280,35 +292,39 @@ RVA `0x7170`, участок `0x71E7–0x723C`, копирует MinZ/MaxZ в re
[таблице бинарников](original-binaries.md). Нативные значения этих двух полей
имеют биты `0x3DCCCCCD` и `0x3F7D70A4`.
-
-## Геометрия визуальных 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`.
+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` столбцы базиса имеют
@@ -359,27 +375,66 @@ RVA `0x93D0`, ветка mode 1 с `0x9739`, сборка столбцов `0x97
семена отдельных экземпляров лишь детерминированно заменяют 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 0a8a2cb..130a203 100644
--- a/docs/reference/msh.md
+++ b/docs/reference/msh.md
@@ -95,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`
diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md
index 10de209..b2a6a93 100644
--- a/docs/reference/render-frame.md
+++ b/docs/reference/render-frame.md
@@ -24,22 +24,87 @@
затенения, вызывается завершение рендерера, снимается `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 сохраняет прежний отдельный
+путь и ещё не декодирован. Это ограничивает приёмку на частично отсечённых
+порталах и не позволяет считать интерьер принятой эталонной сценой.
+
+## Дистанционная кривая тумана
+
+Программный `CShade::ShadeIndexedStrided` в GOG Terrain.dll сравнивает квадрат
+дистанции с квадратами ближней и дальней границ. По RVA `0x2EEED–0x2EF45`
+видимость равна `1 - (distance² - near²) / (far² - near²)` между границами,
+единице перед ближней и нулю за дальней. Такой же закон использует отдельный
+путь цвета пакета `0x2F230`. Линейная интерполяция по самой дистанции давала
+слишком плотный туман в середине диапазона.
+
+В работающем оригинале границы 0 и `0x442389E6`, квадрат дистанции
+`0x47707B04` дали видимость `0x3F5B2B94`. Rust-тест проверяет передаваемые GPU
+квадраты границ по этому наблюдению. GLSL вычисляет квадрат расстояния вершины,
+после чего растеризатор интерполирует коэффициент по треугольнику.
+`native_fog.effective.sink_rgb` совпадает с `sky.packed[1]`, нижней границей
+освещения CShade. Это не подтверждает значение GPU fog color. Приложение пока
+сохраняет цвет экранного горизонта; выбор цвета и проходов требует отдельной
+проверки GPU-состояния.
+Выбор проходов по native shade-state пока приближён общим туманом мира и
+считается как `render_coverage fog_pass_eligibility_approx`.
+
+## Радиус Control Op1
+
+GOG `Effect.dll` (SHA-256
+`386a3e8db1ce23f61957825d633c7dae1ed40c25cab0cf10a534385fd7cee789`),
+RVA `0xF6E0`, преобразует направление текущей матрицей, сохраняет его длину
+перед нормализацией и умножает на неё интерполированный локальный радиус.
+Умножение видно по RVA `0xFB13`; перед LightManager `+0x10` неположительный
+результат заменяется на `0.01`. В живом «Конструкторе» для синего источника
+завода локальный радиус 7 и длина `0x409B1F91` дали радиус `0x4207BB9F`.
+Это же значение проверяется Rust-тестом общего Op1 evaluator.
+
+Случайные добавки цвета и радиуса ещё не воспроизводятся; приложение сообщает
+число таких Control-команд как `control_op1_jitter_unsupported` и использует
+их авторские значения. У синего источника в проверенном захвате добавки нулевые.
## Элемент отрисовки
@@ -117,6 +182,10 @@ RVA `0x13D90`. Эти данные подтверждают только порÑ
путь превью принимает входные данные с матрицей `selector0_words`, полученные через
[процедуру захвата нативного кадра](../../tools/native-frame-capture/README.md).
+Формат захвата сохраняет локальную матрицу `selector0_words` и, когда она
+доступна, записывает `effective_world_matrix_words` с маркером пространства
+`world`.
+
Процедура захвата записывает наблюдавшийся путь миссии и значение
`atmosphere_seconds`, если проверка потока, поколения и часов прошла успешно.
В превью с фиксированной камерой эта абсолютная фаза расписания используется,
@@ -163,14 +232,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
@@ -183,15 +264,9 @@ 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 и матрица камеры
+применяются при следующей отрисовке.
Проекция направления не сравнивает косинус угла с ближней дистанцией: она
отбрасывает только лучи за камерой и почти параллельные плоскости изображения
@@ -254,6 +329,11 @@ D3D7 в `fparkan-render`, а затем один раз меняет знак в
- переход на запасной набор возможностей устройства;
- квантование времени симуляции.
-Для отладки требуется детерминированный захват кадра: состояние камеры,
-идентификаторы видимых объектов, список элементов отрисовки, ключи конвейера,
-матрицы и хэши промежуточных буферов.
+Параметр `--readback-sequence-out` сохраняет необработанные байты каждого
+отправленного кадра; в имя файла записываются индекс кадра, значение общих
+управляемых часов и размер изображения. Байты соответствуют выбранному формату
+изображения swapchain Vulkan и не гарантируют порядок каналов R, G, B, A. Числовое
+формат выводится как `readback_format`; расширение `.rgba` само по себе порядок
+каналов не определяет. Последовательность не содержит структурированных ID
+видимых объектов, списка элементов отрисовки, ключей конвейера, матриц или
+хэшей промежуточных буферов.
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index ef7dc52..72b0685 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,48 @@ 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. Радиус источника масштабируется длиной его преобразованного
+направления до нормализации. Например, локальный радиус 7 при масштабе узла
+около 4,85 освещает область радиусом около 34. Потеря этого множителя оставляла
+синий свет завода почти незаметным. Неположительный итог заменяется на 0,01.
+Случайные добавки к цвету и радиусу пока явно считаются в
+`render_coverage control_op1_jitter_unsupported`.
+Туман также зависит от расстояния нелинейно: между ближней и дальней границами
+движок интерполирует по квадрату дистанции. При границах 0 и 700 точка в 350
+получает четверть тумана, а не половину. Это сохраняет контраст среднего плана.
+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,54 +1086,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`: над конечной поверхностью сохраняется начальный просвет, а вне
-поверхности высота не ограничивается.
-
-Для каждого отрисовываемого строения (корневого объекта класса `FORT`) и для
-каждого его отрисовываемого компонента создаётся отдельная запись проверки. Она
-использует те же текущие матрицы узлов и присоединённых компонентов, что и
-геометрия.
-После ограничения целевой позиции по TerrainWorld камера проверяет непрерывный
-отрезок от прежней позиции до цели; при пересечении она остаётся в прежней
-позиции, сохраняя новый поворот. Повторная отправка ожидающего кадра не
-применяет движение второй раз. Это ограничение диагностической свободной
-камеры, а не правило игрового или следящего режима оригинала.
-
+`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 3a4e51c..6edfb7a 100644
--- a/docs/tomes/06-behavior.md
+++ b/docs/tomes/06-behavior.md
@@ -1442,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`; этот коэффициент не является ставкой
diff --git a/tools/native-frame-capture/NativeFrameCapture.cs b/tools/native-frame-capture/NativeFrameCapture.cs
index 6371ae6..f41fb42 100644
--- a/tools/native-frame-capture/NativeFrameCapture.cs
+++ b/tools/native-frame-capture/NativeFrameCapture.cs
@@ -201,6 +201,8 @@ internal static partial class NativeFrameCapture
private sealed class SelectedCameraState
{
public SelectedCameraInput Input;
+ public int PrecedingFramesRemaining;
+ public int PrecedingFramesCompleted;
public uint CandidateCameraPointer;
public uint CandidateThreadId;
public uint CandidateGeneration;
@@ -226,6 +228,47 @@ internal static partial class NativeFrameCapture
public uint OriginalParentPointer;
}
+ private static SelectedCameraState NextSelectedWarmupFrame(SelectedCameraState previous)
+ {
+ if (previous.PrecedingFramesRemaining <= 0 || !previous.RestoreVerified
+ || !previous.Restored || !previous.MatrixIntactAtReturn
+ || !previous.ProjectionMatchesInput || previous.PixelAttributionInvalidated)
+ throw new InvalidOperationException("Cannot advance a selected camera before a verified frame and exact restoration.");
+ return new SelectedCameraState
+ {
+ Input = previous.Input,
+ PrecedingFramesRemaining = previous.PrecedingFramesRemaining - 1,
+ PrecedingFramesCompleted = previous.PrecedingFramesCompleted + 1,
+ CandidateCameraPointer = previous.CameraPointer,
+ CandidateThreadId = previous.ThreadId,
+ CandidateGeneration = previous.Generation,
+ CandidateProjectionVerified = true
+ };
+ }
+
+ private static void SelfCheckSelectedCameraWarmup()
+ {
+ SelectedCameraState first = new SelectedCameraState
+ {
+ PrecedingFramesRemaining = 2, CameraPointer = 123, ThreadId = 11, Generation = 8,
+ Restored = true, RestoreVerified = true, MatrixIntactAtReturn = true,
+ ProjectionMatchesInput = true
+ };
+ SelectedCameraState second = NextSelectedWarmupFrame(first);
+ if (second.PrecedingFramesRemaining != 1 || second.PrecedingFramesCompleted != 1
+ || second.CandidateCameraPointer != 123 || second.CandidateThreadId != 11
+ || second.CandidateGeneration != 8 || second.Applied || second.RestoreVerified)
+ throw new InvalidOperationException("Selected camera warmup transition self-check failed.");
+ bool unrestoredRejected = false;
+ try { NextSelectedWarmupFrame(second); }
+ catch (InvalidOperationException) { unrestoredRejected = true; }
+ if (!unrestoredRejected) throw new InvalidOperationException("Unrestored warmup frame was accepted.");
+ second.Restored = second.RestoreVerified = second.MatrixIntactAtReturn = second.ProjectionMatchesInput = true;
+ SelectedCameraState final = NextSelectedWarmupFrame(second);
+ if (final.PrecedingFramesRemaining != 0 || final.PrecedingFramesCompleted != 2)
+ throw new InvalidOperationException("Selected camera final frame self-check failed.");
+ }
+
private sealed class BoundaryTraceProgress
{
public bool BeginSceneSeen;
@@ -1236,6 +1279,7 @@ internal static partial class NativeFrameCapture
return CanArmPresentForFrame(frame, threadId)
&& (!frame.SelectedCameraRequested || (frame.SelectedCamera != null
&& frame.SelectedCamera.Applied && frame.SelectedCamera.ProjectionMatchesInput
+ && frame.SelectedCamera.PrecedingFramesRemaining == 0
&& frame.SelectedCamera.CandidateProjectionVerified
&& frame.SelectedCamera.Restored && frame.SelectedCamera.RestoreVerified
&& frame.SelectedCamera.MatrixIntactAtReturn
@@ -1276,6 +1320,7 @@ internal static partial class NativeFrameCapture
&& frame.CameraThreadId == threadId && frame.ProjectionThreadId == threadId
&& (!frame.SelectedCameraRequested || (frame.SelectedCamera != null
&& frame.SelectedCamera.Applied && frame.SelectedCamera.ProjectionMatchesInput
+ && frame.SelectedCamera.PrecedingFramesRemaining == 0
&& frame.SelectedCamera.CandidateProjectionVerified
&& !frame.SelectedCamera.PixelAttributionInvalidated
&& SelectedMatrixMatchesProjection(frame)));
@@ -1848,6 +1893,7 @@ internal static partial class NativeFrameCapture
{
SelectedCameraState selected = frame.SelectedCamera;
json.AppendLine(" \"selected_camera\": {");
+ json.AppendLine(" \"preceding_selected_world_frames\": " + selected.PrecedingFramesCompleted + ",");
json.AppendLine(" \"input_json\": " + JsonString(selected.Input.Path) + ",");
json.AppendLine(" \"input_kind\": " + JsonString(selected.Input.Synthetic
? "synthetic-camera-plan" : "native-capture") + ",");
@@ -1949,6 +1995,7 @@ internal static partial class NativeFrameCapture
{
if (IntPtr.Size != 4) throw new InvalidOperationException("This helper must be compiled and run as x86.");
SelfCheckCameraInput();
+ SelfCheckSelectedCameraWarmup();
SelfCheckNativeFogPairing();
SelfCheckRenderInvocationGates();
SelfCheckDebugEventHandleOwnership();
@@ -2088,6 +2135,10 @@ internal static partial class NativeFrameCapture
bool wrongCameraThreadRejected = !IsMatchingPerspectiveFrame(matched, 12);
bool emptyCameraFrameRejected = !IsMatchingPerspectiveFrame(new FrameSnapshot(), 11);
bool selectedFrameValid = IsMatchingPerspectiveFrame(selectedMatched, 11);
+ selectedMatched.SelectedCamera.PrecedingFramesRemaining = 1;
+ bool selectedWarmupRejected = !CanArmPresentForFrame(selectedMatched, 11)
+ && !IsMatchingPerspectiveFrame(selectedMatched, 11);
+ selectedMatched.SelectedCamera.PrecedingFramesRemaining = 0;
bool selectedUnrestoredRejected = !IsMatchingPerspectiveFrame(selectedUnrestored, 11);
bool selectedUnrestoredCaptureEligible = CanArmPresentForFrame(selectedUnrestored, 11);
bool selectedIntervenedCaptureRejected = !CanArmPresentForFrame(selectedIntervened, 11);
@@ -2189,7 +2240,7 @@ internal static partial class NativeFrameCapture
|| !atmosphereFrameValid
|| !atmosphereClockMismatchRejected
|| !cameraFrameValid || !wrongCameraThreadRejected || !emptyCameraFrameRejected
- || !selectedFrameValid || !selectedUnrestoredRejected || !selectedUnrestoredCaptureEligible
+ || !selectedFrameValid || !selectedWarmupRejected || !selectedUnrestoredRejected || !selectedUnrestoredCaptureEligible
|| !selectedIntervenedCaptureRejected || !selectedIntervenedRejected
|| !selectedChangedMatrixRejected
|| !selectedBoundaryReadAllowed || !boundaryReadRejectsUnselected
@@ -2919,7 +2970,8 @@ internal static partial class NativeFrameCapture
bool projectionSelectorMode = boundaryTrace || boundaryCapture;
frame.SelectedCameraRequested = selectedCameraInput != null;
SelectedCameraState selectedCamera = selectedCameraInput == null ? null
- : new SelectedCameraState { Input = selectedCameraInput };
+ : new SelectedCameraState { Input = selectedCameraInput,
+ PrecedingFramesRemaining = boundaryCapture ? 0 : 2 };
try
{
if (identityProcess == null || identityProcess.HasExited
@@ -3330,6 +3382,12 @@ internal static partial class NativeFrameCapture
Log("CAMERA_SETTER_REGION_FREED phase=Restore");
else Log("CAMERA_SETTER_REGION_FREE_FAILED phase=Restore error=" + Marshal.GetLastWin32Error());
cameraSetter = null;
+ if (!stopAfterAttempt && selectedCamera.PrecedingFramesRemaining > 0)
+ {
+ selectedCamera = NextSelectedWarmupFrame(selectedCamera);
+ Log("SELECTED_CAMERA_WARMUP_COMPLETE completed=" + selectedCamera.PrecedingFramesCompleted
+ + " remaining=" + selectedCamera.PrecedingFramesRemaining);
+ }
}
}
}
@@ -3870,7 +3928,8 @@ internal static partial class NativeFrameCapture
&& targetProjectionMatchesInput)
+ " rawWorldTime=0x" + frame.WorldGameTimeWord.ToString("X8"));
if (cameraWordsCurrent && frame.Projection.Verified && frame.Projection.Mode != 0
- && targetProjectionMatchesInput && ngiBase != 0 && presentBoundaryVerified)
+ && targetProjectionMatchesInput && ngiBase != 0 && presentBoundaryVerified
+ && (selectedCamera == null || selectedCamera.PrecedingFramesRemaining == 0))
EnsurePresentBreakpoint(process, ngiBase, frame, eventTid,
presentOriginalByte, ref presentBreakpoint);
}
@@ -3975,7 +4034,7 @@ internal static partial class NativeFrameCapture
}
cameraSetter = StartCameraSetter(process, eventTid, terrainBase,
selectedCamera.CameraPointer, selectedCamera.OriginalMatrixBytes,
- stoppedContext, returnBreakpoint, false, CameraSetterPhase.Restore,
+ stoppedContext, returnBreakpoint, selectedCamera.PrecedingFramesRemaining > 0, CameraSetterPhase.Restore,
selectedCamera.EntryEsp, selectedCamera.ReturnAddress);
Log("SELECTED_CAMERA_RESTORE_STARTED generation=" + selectedCamera.Generation
+ " camera=0x" + selectedCamera.CameraPointer.ToString("X8")
diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md
index 428f8c2..232cc1c 100644
--- a/tools/native-frame-capture/README.md
+++ b/tools/native-frame-capture/README.md
@@ -74,6 +74,8 @@ if ($LASTEXITCODE -ne 0) { throw 'Selected-camera capture failed; inspect its lo
This verifies repeatable camera handling, not deterministic whole-scene pixels. The helper has no setter for FOV, near/far planes, or fog, and does not freeze simulation state; `atmosphere_seconds` remains an observed phase sample rather than a restored simulation clock. If the callsite probe cannot verify a mission path, `mission_path` remains `null` and `mission_identity` remains `unknown`.
+For `--capture --camera-input`, the requested pose is rendered on three consecutive world frames. The first two are discarded so portal visibility and other render caches can settle. The original local matrix is restored and checked after every frame; the third frame alone supplies the pixels, projection and atmosphere sample. `selected_camera.preceding_selected_world_frames` records the two preceding verified frames. A failed matrix or restoration check stops the sequence. Passive and EndScene-boundary capture keep their single-frame behavior.
+
For a second capture, reuse the still-running PID only after the first run detached successfully, and choose a new output basename. Reuse the same validated camera input if comparing the same pose. The helper rejects any existing JSON, PNG, or log with the chosen basename and never overwrites those files. After a successful one-shot capture it detaches and leaves the scratch game running. It explicitly closes only image-file handles from `CREATE_PROCESS` and `LOAD_DLL`; the system owns the process/thread handles supplied by `CREATE_PROCESS` and `CREATE_THREAD` until the corresponding exit event is continued. If guarded recovery must terminate the scratch process, the helper drains its outstanding and queued debug events through `EXIT_PROCESS` within five seconds, then verifies the process handle before detaching. It withholds output when process exit cannot be confirmed.
To compare a viewer frame with a native capture, use the same phase JSON for the camera and the PNG conversion. When `atmosphere_seconds` is present, the viewer uses it unless `--atmosphere-seconds` is given, and holds the atmosphere schedule at that sample for the fixed-camera readback. Choose fresh `viewer.bin` and `viewer.png` paths because the viewer writes the raw file and conversion refuses to overwrite an existing PNG.
@@ -110,3 +112,42 @@ 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. Это значения сохранённого изображения,
+без перевода в линейное цветовое пространство. Скрипт отклоняет несовпадающие
+размеры, области за границей кадра и существующие выходные файлы. Совпадение
+средних цветов не доказывает совпадение геометрии, поэтому пару нужно также
+просмотреть. Камера и фаза должны происходить из одного нативного захвата.
+Для интерьера нужно проверить наличие комнат в PNG: выбранная камера проходит
+два предварительных мировых кадра, а при пассивном захвате игрок входит в здание.
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() }
+ }
+}