aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock14
-rw-r--r--Cargo.toml1
-rw-r--r--README.md46
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs12
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs154
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs32
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs93
-rw-r--r--apps/fparkan-game/Cargo.toml4
-rw-r--r--apps/fparkan-game/src/main.rs10947
-rw-r--r--crates/fparkan-assets/src/lib.rs2086
-rw-r--r--crates/fparkan-assets/src/research.rs1151
-rw-r--r--crates/fparkan-font/Cargo.toml12
-rw-r--r--crates/fparkan-font/src/lib.rs402
-rw-r--r--crates/fparkan-fx/src/ambient_op3.rs218
-rw-r--r--crates/fparkan-msh/src/lib.rs924
-rw-r--r--crates/fparkan-render/src/lib.rs127
-rw-r--r--crates/fparkan-runtime/Cargo.toml4
-rw-r--r--crates/fparkan-runtime/src/lib.rs4282
-rw-r--r--crates/fparkan-runtime/src/mesh_collision.rs1255
-rw-r--r--crates/fparkan-script/src/lib.rs1476
-rw-r--r--crates/fparkan-terrain/src/lib.rs16
-rw-r--r--docs/appendices/knowledge-boundaries.md447
-rw-r--r--docs/appendices/script-vm.md530
-rw-r--r--docs/appendices/ui-shell.md110
-rw-r--r--docs/reference/environment-effects.md216
-rw-r--r--docs/reference/msh.md154
-rw-r--r--docs/reference/render-frame.md155
-rw-r--r--docs/tomes/03-resources.md15
-rw-r--r--docs/tomes/05-render.md166
-rw-r--r--docs/tomes/06-behavior.md325
-rw-r--r--tools/native-frame-capture/README.md38
-rw-r--r--tools/native-frame-capture/compare-frames.ps1106
32 files changed, 22878 insertions, 2640 deletions
diff --git a/Cargo.lock b/Cargo.lock
index b533543..ff96caa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -384,9 +384,9 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "encoding_rs"
-version = "0.8.42"
+version = "0.8.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
+checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5"
dependencies = [
"cfg-if",
"core_detect",
@@ -488,6 +488,14 @@ name = "fparkan-binary"
version = "0.1.0"
[[package]]
+name = "fparkan-font"
+version = "0.1.0"
+dependencies = [
+ "fparkan-nres",
+ "fparkan-texm",
+]
+
+[[package]]
name = "fparkan-fx"
version = "0.1.0"
dependencies = [
@@ -629,7 +637,9 @@ dependencies = [
name = "fparkan-runtime"
version = "0.1.0"
dependencies = [
+ "fparkan-animation",
"fparkan-assets",
+ "fparkan-fx",
"fparkan-msh",
"fparkan-path",
"fparkan-platform",
diff --git a/Cargo.toml b/Cargo.toml
index 847b23c..5711d16 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,7 @@ members = [
"crates/fparkan-animation",
"crates/fparkan-assets",
"crates/fparkan-binary",
+ "crates/fparkan-font",
"crates/fparkan-fx",
"crates/fparkan-material",
"crates/fparkan-mission-format",
diff --git a/README.md b/README.md
index 20442e1..663d4cd 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,10 @@
интерактивную сцену выбранной карты: terrain, все доступные MSH-компоненты
объектов, материалы, динамические тени, небо, погоду, lightning FX и звуковые
события. Это просмотр карты с полной графической сценой; прохождение миссии и
-игровое поведение юнитов пока не запускаются, а модели показываются в статической
-позе, заданной сохранёнными настройками узлов прототипа.
+игровое поведение юнитов пока не запускаются. Модели начинают с настроек узлов
+прототипа, а поддерживаемые Control-анимации и эффекты обновляются по общим
+часам сцены. Неподдерживаемые Control-треки остаются на сохранённой начальной
+позе.
## Запуск
@@ -28,6 +30,14 @@ cargo run --release -p fparkan-game -- `
потере фокуса обзор отключается и нажатые клавиши сбрасываются. `Esc`
отпускает обзор или закрывает окно.
+В интерактивном режиме каждое отрисовываемое строение (корневой объект класса
+`FORT`) и его отрисовываемые компоненты участвуют в проверке движения с их
+позами и преобразованиями присоединения. Камера проверяет непрерывный путь до
+новой позиции после ограничения рельефом; при пересечении она остаётся на
+прежней позиции, сохраняя новый поворот. Повторная отправка ожидающего кадра не
+применяет шаг второй раз. Режим `--legacy-camera-capture` не использует
+свободную камеру и сохраняет положение из захвата без изменений.
+
В каталоге миссии ищется `sky.ske`. Если файл найден, приложение читает её
расписание и `sky.wea`; связанные MAT0/WEAR-материалы и
`effects.rlb/env_lightning` загружаются из ресурсов игры. Без `sky.ske`
@@ -38,23 +48,21 @@ cargo run --release -p fparkan-game -- `
луна, flares, молнии, дождь и снег, point lights, динамические тени и связанные
звуковые события обновляются в каждом кадре.
-`--frames N` ограничивает запуск числом представленных кадров для
-smoke/readback-проверок;
-значение `0` означает бесконечный интерактивный запуск. `--control-clock-ms N`
-воспроизводит общий clock анимации MSH и материалов вместе с окружением от нуля
-до `N` за `--frames` кадров и требует не менее двух кадров. Пересоздание
-swapchain повторяет текущий sample времени и состояние мира; перед повторной
-отправкой обновляются camera projection и viewport-зависимая геометрия. Это не
-продвигает сцену или replay clock. Счётчик и replay clock переходят дальше
-после представленного кадра.
-`--legacy-camera-capture` читает сохранённый захват камеры и запускает
-воспроизводимый режим без
-free-flight управления. `--static-animation-frame <u16>` задаёт один кадр для
-всех узлов. Если параметр не указан, приложение берёт сохранённые настройки
-узлов из явной ссылки прототипа на `.ctl`; для узлов без такой настройки
-используется кадр `0`. Статическая поза не запускает игровой Control, AI или
-полное поведение юнитов.
-`--static-material-phase <u16>` выбирает фиксированную фазу материала.
+`--frames N` ограничивает запуск числом кадров для smoke/readback-проверок;
+значение `0` означает бесконечный интерактивный запуск. `--legacy-camera-capture`
+читает сохранённый захват камеры и запускает воспроизводимый режим без
+free-flight управления. В обычном запуске накопленный runtime clock обновляет
+MSH Control-анимацию, поддержанные Control FX и анимацию материалов.
+`--control-clock-ms <u32>` вместе с `--frames N` (не меньше двух) воспроизводит
+этот clock от нуля до заданного миллисекундного значения за выбранные кадры.
+
+`--static-animation-frame <u16>` задаёт один кадр для всех MSH-узлов и
+выключает выбор MSH-позы из Control clock. Без override приложение берёт
+начальные настройки узлов из явных `.ctl` bindings, затем применяет
+поддержанные Control-треки. Узлы без настройки остаются на кадре `0`. Это не
+запускает AI, миссионные события или полное поведение юнитов.
+`--static-material-phase <u16>` фиксирует фазу MSH и terrain материалов; фаза
+материала Control FX остаётся частью его normalized command timeline.
`--readback-out target/frame.raw` сохраняет последний кадр GPU. `--validation`
включает Vulkan validation layers и завершает запуск с ошибкой при предупреждении
или ошибке. Readback сохраняет байты в формате surface без преобразования;
diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
index 3249012..f691b34 100644
--- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs
+++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
@@ -1379,8 +1379,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices,
batches,
+ triangle_descriptors: None,
node_names: None,
animation: None,
}
@@ -1438,6 +1440,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: vec![
batch(0, 0, 0),
@@ -1446,6 +1449,7 @@ mod tests {
..batch(0, 0, 0)
},
],
+ triangle_descriptors: None,
node_names: None,
animation: None,
};
@@ -1684,8 +1688,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: vec![0, 1, 2, 0, 1, 2],
batches: vec![batch(0, 3, 0), batch(3, 3, 0)],
+ triangle_descriptors: None,
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
@@ -1743,8 +1749,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
+ triangle_descriptors: None,
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
@@ -1849,8 +1857,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: vec![0, 1, 2, 0, 1, 2],
batches: vec![batch(0, 3, 0), batch(3, 3, 0)],
+ triangle_descriptors: None,
node_names: None,
animation: Some(ModelAnimation {
keys: vec![AnimKey24 {
@@ -1940,8 +1950,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: vec![0, 1, 2],
batches: vec![batch(0, 3, 0)],
+ triangle_descriptors: None,
node_names: None,
animation: Some(ModelAnimation {
keys: vec![AnimKey24 {
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
index a2481f7..0e1cebb 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
@@ -423,6 +423,7 @@ impl VulkanSmokeRenderer {
draw_range_sort_keys: vec![None; create_info.mesh.draw_ranges.len()],
draw_order_dirty: false,
draw_range_world_queue: vec![None; create_info.mesh.draw_ranges.len()],
+ draw_range_diffuse_alpha_overrides: vec![None; create_info.mesh.draw_ranges.len()],
world_transparent_background_ranges: Vec::new(),
draw_range_base_pipeline_states: create_info
.mesh
@@ -901,6 +902,27 @@ impl VulkanSmokeRenderer {
Ok(())
}
+ /// Overrides one draw's diffuse alpha without changing its shared material.
+ pub fn set_draw_range_diffuse_alpha_override(
+ &mut self,
+ range_index: usize,
+ diffuse_alpha: Option<f32>,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ if diffuse_alpha.is_some_and(|alpha| !alpha.is_finite() || !(0.0..=1.0).contains(&alpha)) {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw-range diffuse alpha must be finite and 0..1",
+ });
+ }
+ let alpha_override = self
+ .draw_range_diffuse_alpha_overrides
+ .get_mut(range_index)
+ .ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw-range alpha index is out of bounds",
+ })?;
+ *alpha_override = diffuse_alpha;
+ Ok(())
+ }
+
/// Sets the native per-range alpha reference in legacy 0..=255 units.
///
/// Zero remains an explicit value; use the native-default constant when
@@ -1808,6 +1830,7 @@ impl VulkanSmokeRenderer {
0,
vk::IndexType::UINT32,
);
+ let mut viewport_depth = None;
for &range_index in &self.draw_order {
let range = self.draw_ranges.get(range_index).ok_or(
VulkanSmokeRendererError::InvariantViolation {
@@ -1873,11 +1896,16 @@ impl VulkanSmokeRenderer {
context: "static material alpha",
},
)?;
- let material_alphas = [
- material_alpha[0].to_ne_bytes(),
- material_alpha[1].to_ne_bytes(),
- ]
- .concat();
+ let diffuse_alpha_override = self
+ .draw_range_diffuse_alpha_overrides
+ .get(range_index)
+ .ok_or(VulkanSmokeRendererError::InvariantViolation {
+ context: "draw-range diffuse-alpha override",
+ })?;
+ let material_alphas =
+ draw_material_alphas(*material_alpha, *diffuse_alpha_override)
+ .map(f32::to_ne_bytes)
+ .concat();
device.device().cmd_push_constants(
command_buffer,
resources.pipeline_layout,
@@ -1929,6 +1957,17 @@ impl VulkanSmokeRenderer {
} else {
*material_mode
};
+ let viewport = super::swapchain_resources::draw_viewport(
+ swapchain.report.plan.extent,
+ material_mode,
+ );
+ let next_viewport_depth = [viewport.min_depth, viewport.max_depth];
+ if viewport_depth != Some(next_viewport_depth) {
+ device
+ .device()
+ .cmd_set_viewport(command_buffer, 0, &[viewport]);
+ viewport_depth = Some(next_viewport_depth);
+ }
let material_mode = [material_mode.to_ne_bytes()];
device.device().cmd_push_constants(
command_buffer,
@@ -2356,6 +2395,16 @@ fn apply_world_transparency(
}
}
+fn draw_material_alphas(
+ material_alphas: [f32; 2],
+ diffuse_alpha_override: Option<f32>,
+) -> [f32; 2] {
+ [
+ diffuse_alpha_override.unwrap_or(material_alphas[0]),
+ material_alphas[1],
+ ]
+}
+
fn world_material_pipeline_states(
original: LegacyPipelineState,
category: u8,
@@ -2523,12 +2572,14 @@ mod tests {
use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh};
use super::{
apply_geometry_range_updates, apply_world_transparency, completed_readback_buffer_index,
- mark_draw_order_dirty_on_activity_change, set_and_mark_dirty_if_changed,
- sorted_world_draw_order, take_runtime_children_with_validation_snapshot,
- take_runtime_owners_in_dependency_order, validate_geometry_upload,
- world_material_pipeline_states, RollbackOnDrop,
+ draw_material_alphas, mark_draw_order_dirty_on_activity_change,
+ set_and_mark_dirty_if_changed, sorted_world_draw_order,
+ take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order,
+ validate_geometry_upload, world_material_pipeline_states, RollbackOnDrop,
+ };
+ use fparkan_render::{
+ LegacyBlendMode, LegacyCullMode, LegacyDepthBias, LegacyDepthMode, LegacyPipelineState,
};
- use fparkan_render::{LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState};
use std::cell::RefCell;
use std::rc::Rc;
@@ -3096,12 +3147,74 @@ mod tests {
}
#[test]
+ fn control_fx_queue_follows_opaque_and_sky_without_reactivating_empty_ranges() {
+ const SCREEN_GRADIENT: usize = 0;
+ const CONTROL_FX: usize = 1;
+ const TERRAIN: usize = 2;
+ const BUILDING: usize = 3;
+ const SHADOW: usize = 4;
+ const NEBULA: usize = 5;
+ const DOME: usize = 6;
+ const CLOUDS: usize = 7;
+ const EMPTY_CONTROL_FX: usize = 8;
+ const SUN: usize = 9;
+ const WEATHER: usize = 10;
+ let mut ranges = (0..11)
+ .map(|index| VulkanStaticDrawRange {
+ first_index: index * 3,
+ index_count: 3,
+ material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
+ alpha_test_reference: 0,
+ })
+ .collect::<Vec<_>>();
+ ranges[EMPTY_CONTROL_FX].index_count = 0;
+ let mut world_queue = vec![None; ranges.len()];
+ world_queue[TERRAIN] = Some(false);
+ world_queue[BUILDING] = Some(false);
+ world_queue[CONTROL_FX] = Some(true);
+ world_queue[EMPTY_CONTROL_FX] = Some(true);
+ let mut sort_keys = vec![None; ranges.len()];
+ sort_keys[CONTROL_FX] = Some(20.0);
+ sort_keys[EMPTY_CONTROL_FX] = Some(30.0);
+
+ let order = sorted_world_draw_order(
+ ranges.len(),
+ &ranges,
+ &world_queue,
+ &sort_keys,
+ &[NEBULA, DOME, CLOUDS],
+ );
+ assert_eq!(
+ order,
+ vec![
+ SCREEN_GRADIENT,
+ TERRAIN,
+ BUILDING,
+ SHADOW,
+ NEBULA,
+ DOME,
+ CLOUDS,
+ CONTROL_FX,
+ EMPTY_CONTROL_FX,
+ SUN,
+ WEATHER,
+ ]
+ );
+ assert_eq!(ranges[EMPTY_CONTROL_FX].index_count, 0);
+ }
+
+ #[test]
fn material_category_and_transparency_preserve_world_state_in_either_order() {
let original = LegacyPipelineState {
blend: LegacyBlendMode::Opaque,
depth: LegacyDepthMode::TestWrite,
cull: LegacyCullMode::FrontFace,
alpha_test: false,
+ depth_bias: LegacyDepthBias::ProjectedDecal,
};
let (base, category_first) =
world_material_pipeline_states(original, 1, Some(true)).expect("category 1");
@@ -3113,14 +3226,17 @@ mod tests {
assert!(base.alpha_test);
assert_eq!(base.depth, LegacyDepthMode::TestWrite);
assert_eq!(base.cull, LegacyCullMode::FrontFace);
+ assert_eq!(base.depth_bias, LegacyDepthBias::ProjectedDecal);
assert_eq!(category_first.depth, LegacyDepthMode::TestReadOnly);
assert_eq!(category_first.cull, LegacyCullMode::FrontFace);
+ assert_eq!(category_first.depth_bias, LegacyDepthBias::ProjectedDecal);
let fixed_state = LegacyPipelineState {
blend: LegacyBlendMode::Additive,
depth: LegacyDepthMode::Disabled,
cull: LegacyCullMode::Disabled,
alpha_test: false,
+ depth_bias: LegacyDepthBias::Disabled,
};
assert_eq!(
world_material_pipeline_states(fixed_state, 1, None),
@@ -3129,4 +3245,22 @@ mod tests {
assert!(world_material_pipeline_states(original, 5, Some(false)).is_none());
assert!(world_material_pipeline_states(fixed_state, 5, None).is_none());
}
+
+ #[test]
+ fn draw_alpha_override_is_per_range_and_none_restores_material_alpha() {
+ let shared_material = [0.25, 0.5];
+
+ assert_eq!(draw_material_alphas(shared_material, Some(0.0)), [0.0, 0.5]);
+ assert_eq!(draw_material_alphas(shared_material, None), shared_material);
+
+ let updated_material = [0.75, 0.125];
+ assert_eq!(
+ draw_material_alphas(updated_material, None),
+ updated_material
+ );
+ assert_eq!(
+ draw_material_alphas(updated_material, Some(0.0)),
+ [0.0, 0.125]
+ );
+ }
}
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
index 51f8384..278243b 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
@@ -756,8 +756,8 @@ impl VulkanStaticMesh {
material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
- terrain_base_only: false,
pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
alpha_test_reference: 0,
}],
}
@@ -992,8 +992,8 @@ mod static_mesh_tests {
material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
- terrain_base_only: false,
pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
alpha_test_reference: 0,
}],
};
@@ -1023,8 +1023,8 @@ mod static_mesh_tests {
material_index: 7,
lightmap_index: u8::MAX,
batch_flags: 0,
- terrain_base_only: false,
pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
alpha_test_reference: 0,
},
VulkanStaticDrawRange {
@@ -1033,8 +1033,8 @@ mod static_mesh_tests {
material_index: 2,
lightmap_index: u8::MAX,
batch_flags: 0,
- terrain_base_only: false,
pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
alpha_test_reference: 0,
},
];
@@ -1101,6 +1101,11 @@ mod static_mesh_tests {
},
];
+ assert_eq!(
+ resolve_draw_texture_indices(&ranges, &materials),
+ Ok(vec![1, 0])
+ );
+
let mut screen_tl = materials[0].clone();
screen_tl.screen_tl = true;
screen_tl.sky_far_depth = true;
@@ -1109,11 +1114,9 @@ mod static_mesh_tests {
far_sky.unlit = true;
far_sky.sky_far_depth = true;
assert_eq!(far_sky.encoded_material_mode(), 5.0);
-
- assert_eq!(
- resolve_draw_texture_indices(&ranges, &materials),
- Ok(vec![1, 0])
- );
+ let mut nebula_with_stars = far_sky.clone();
+ nebula_with_stars.sky_nebula_stars = true;
+ assert_eq!(nebula_with_stars.encoded_material_mode(), 6.0);
let mut non_finite_base = materials.clone();
non_finite_base[0].specular_rgb[0] = f32::NAN;
@@ -1149,6 +1152,7 @@ mod static_mesh_tests {
fn alpha_cutoff_is_dynamic_and_disabled_outside_alpha_test_pipeline() {
let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0];
let disabled = VulkanStaticDrawRange {
+ terrain_base_only: false,
alpha_test_reference: 200,
..base
};
@@ -1157,6 +1161,7 @@ mod static_mesh_tests {
alpha_test: true,
..LegacyPipelineState::default()
},
+ terrain_base_only: false,
alpha_test_reference: 128,
..base
};
@@ -1167,6 +1172,7 @@ mod static_mesh_tests {
alpha_test: true,
..LegacyPipelineState::default()
},
+ terrain_base_only: false,
alpha_test_reference: 0,
..base
};
@@ -1176,6 +1182,7 @@ mod static_mesh_tests {
alpha_test: true,
..LegacyPipelineState::default()
},
+ terrain_base_only: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
..base
};
@@ -1456,6 +1463,9 @@ pub struct VulkanSmokeRenderer {
/// a fixed screen/sky/weather pass; `Some(false)` is world opaque and
/// `Some(true)` is world transparent.
pub(super) draw_range_world_queue: Vec<Option<bool>>,
+ /// Optional diffuse-alpha override for each draw range, independent of
+ /// the shared material record.
+ pub(super) draw_range_diffuse_alpha_overrides: Vec<Option<f32>>,
/// Fixed sky background ranges placed between world opaque and transparent.
pub(super) world_transparent_background_ranges: Vec<usize>,
/// Pipeline state before transparent queue depth policy is applied.
@@ -1469,8 +1479,8 @@ pub struct VulkanSmokeRenderer {
pub(super) material_selectors: Vec<u16>,
/// Whether the host-side specular records need a fence-safe GPU upload.
pub(super) material_specular_dirty: bool,
- /// Encoded material mode pushed at offset 124: 0 lit, 1 unlit, 2 native
- /// sky nebula/stars texture composite, 3 native lightmap composite.
+ /// Encoded material mode pushed at offset 124: low bits select the
+ /// combiner, bit 4 selects sky far-depth, and bit 8 selects screen/TL.
pub(super) material_unlit: Vec<f32>,
pub(super) readback_enabled: bool,
pub(super) camera: VulkanStaticCamera,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
index 89ca43a..973e8b1 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
@@ -3,7 +3,8 @@
use ash::vk;
use fparkan_platform::DepthStencilSupport;
use fparkan_render::{
- LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState, PipelineKey,
+ LegacyBlendMode, LegacyCullMode, LegacyDepthBias, LegacyDepthMode, LegacyPipelineState,
+ PipelineKey,
};
use std::collections::BTreeMap;
@@ -922,8 +923,25 @@ fn create_texture_descriptor_bundle(
Ok((layout, pool, descriptor_sets, sampler))
}
-fn extent_component_to_f32(value: u32) -> f32 {
- value as f32
+pub(super) fn draw_viewport(extent: (u32, u32), material_mode: f32) -> vk::Viewport {
+ // Ngi32 RVA 0x7170 maps world depth to 0.1..0.99; TL and sky depth bypass it.
+ let explicit_depth = material_mode.round() as u32 & 12 != 0;
+ vk::Viewport {
+ x: 0.0,
+ y: 0.0,
+ width: extent.0 as f32,
+ height: extent.1 as f32,
+ min_depth: if explicit_depth { 0.0 } else { 0.1 },
+ max_depth: if explicit_depth { 1.0 } else { 0.99 },
+ }
+}
+
+fn depth_bias_parameters(depth_bias: LegacyDepthBias) -> (bool, f32, f32) {
+ match depth_bias {
+ LegacyDepthBias::Disabled => (false, 0.0, 0.0),
+ // shortcut: Vulkan constantFactor=-1.0/slopeFactor=0.0 is empirical; verify native raster state before claiming parity.
+ LegacyDepthBias::ProjectedDecal => (true, -1.0, 0.0),
+ }
}
fn create_graphics_pipeline_cache(
@@ -1033,14 +1051,7 @@ fn create_graphics_pipeline(
let input_assembly_state = vk::PipelineInputAssemblyStateCreateInfo::default()
.topology(vk::PrimitiveTopology::TRIANGLE_LIST)
.primitive_restart_enable(false);
- let viewports = [vk::Viewport {
- x: 0.0,
- y: 0.0,
- width: extent_component_to_f32(extent.0),
- height: extent_component_to_f32(extent.1),
- min_depth: 0.0,
- max_depth: 1.0,
- }];
+ let viewports = [draw_viewport(extent, 0.0)];
let scissors = [vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent: vk::Extent2D {
@@ -1051,6 +1062,8 @@ fn create_graphics_pipeline(
let viewport_state = vk::PipelineViewportStateCreateInfo::default()
.viewports(&viewports)
.scissors(&scissors);
+ let (depth_bias_enable, depth_bias_constant_factor, depth_bias_slope_factor) =
+ depth_bias_parameters(state.depth_bias);
let rasterization_state = vk::PipelineRasterizationStateCreateInfo::default()
.depth_clamp_enable(false)
.rasterizer_discard_enable(false)
@@ -1062,7 +1075,10 @@ fn create_graphics_pipeline(
LegacyCullMode::FrontFace => vk::CullModeFlags::FRONT,
})
.front_face(vk::FrontFace::CLOCKWISE)
- .depth_bias_enable(false);
+ .depth_bias_enable(depth_bias_enable)
+ .depth_bias_constant_factor(depth_bias_constant_factor)
+ .depth_bias_slope_factor(depth_bias_slope_factor)
+ .depth_bias_clamp(0.0);
let multisample_state = vk::PipelineMultisampleStateCreateInfo::default()
.sample_shading_enable(false)
.rasterization_samples(vk::SampleCountFlags::TYPE_1);
@@ -1105,6 +1121,9 @@ fn create_graphics_pipeline(
let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default()
.logic_op_enable(false)
.attachments(&color_blend_attachments);
+ let dynamic_states = [vk::DynamicState::VIEWPORT];
+ let dynamic_state =
+ vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states);
let create_info = vk::GraphicsPipelineCreateInfo::default()
.stages(&shader_stages)
.vertex_input_state(&vertex_input_state)
@@ -1114,6 +1133,7 @@ fn create_graphics_pipeline(
.multisample_state(&multisample_state)
.depth_stencil_state(&depth_stencil_state)
.color_blend_state(&color_blend_state)
+ .dynamic_state(&dynamic_state)
.layout(pipeline_layout)
.render_pass(render_pass)
.subpass(0);
@@ -1328,3 +1348,52 @@ fn destroy_partial_swapchain_resources(
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::{depth_bias_parameters, draw_viewport};
+ use fparkan_render::LegacyDepthBias;
+
+ #[test]
+ fn projected_decal_bias_parameters_use_empirical_vulkan_factors() {
+ assert_eq!(
+ depth_bias_parameters(LegacyDepthBias::Disabled),
+ (false, 0.0, 0.0)
+ );
+ assert_eq!(
+ depth_bias_parameters(LegacyDepthBias::ProjectedDecal),
+ (true, -1.0, 0.0)
+ );
+ }
+
+ #[test]
+ fn world_viewport_depth_keeps_distant_geometry_in_front_of_screen_sun() {
+ let projection = fparkan_render::LegacyD3d7Projection {
+ viewport: [0, 0, 1280, 1024],
+ near_plane: 0.5,
+ far_plane: 700.0,
+ field_of_view_radians: 1.04,
+ }
+ .try_direct3d7_projection_row_major()
+ .expect("native projection");
+ for mode in [0.0, 1.0, 2.0, 3.0, 16.0, 17.0] {
+ let viewport = draw_viewport((1280, 1024), mode);
+ assert_eq!([viewport.width, viewport.height], [1280.0, 1024.0]);
+ assert_eq!([viewport.min_depth, viewport.max_depth], [0.1, 0.99]);
+ for distance in [0.5, 100.0, 350.0, 600.0, 700.0] {
+ let clip_z = distance * projection[10] + projection[14];
+ let clip_w = distance * projection[11];
+ let depth = viewport.min_depth
+ + (viewport.max_depth - viewport.min_depth) * (clip_z / clip_w);
+ assert!(
+ depth < 0.999,
+ "mode {mode}, distance {distance}, depth {depth}"
+ );
+ }
+ }
+ for mode in [4.0, 5.0, 6.0, 7.0, 9.0] {
+ let viewport = draw_viewport((640, 480), mode);
+ assert_eq!([viewport.min_depth, viewport.max_depth], [0.0, 1.0]);
+ }
+ }
+}
diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml
index bf0f540..4cb0326 100644
--- a/apps/fparkan-game/Cargo.toml
+++ b/apps/fparkan-game/Cargo.toml
@@ -14,6 +14,7 @@ fparkan-resource = { path = "../../crates/fparkan-resource" }
fparkan-render = { path = "../../crates/fparkan-render" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
fparkan-msh = { path = "../../crates/fparkan-msh" }
+fparkan-material = { path = "../../crates/fparkan-material" }
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
@@ -24,6 +25,3 @@ rodio = { version = "0.23.0", default-features = false, features = ["playback",
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
-
-[dev-dependencies]
-fparkan-material = { path = "../../crates/fparkan-material" }
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index 633b6fe..33ed782 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -4,19 +4,28 @@
use fparkan_animation::{AnimationTime, NodePoseBuffer};
use fparkan_assets::{
+ load_material_archive_name_texture_rgba8_and_phase_with_document_from_root,
load_material_name_texture_rgba8_and_phase_with_document_from_root,
load_resource_entry_bytes_from_root,
load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root,
- page_uv_transform, sample_control_node_times, sample_material_phase, ControlInstanceState,
- ControlSamplingError, Mat0Document, MaterialPhase, MaterialPhaseSample,
- PreparedComponentControl, PreparedControlGateProvider, PreparedControlNodeBinding,
- PreparedMaterialPhase, PreparedTexture, PreparedTextureUsage, PreparedVisual, RgbaImage,
- StandaloneWearMaterialLoader,
+ page_uv_transform, sample_control_node_times, sample_material_normalized_phase,
+ sample_material_phase, ControlInstanceState, ControlSamplingError, Mat0Document, MaterialPhase,
+ MaterialPhaseSample, PreparedComponentControl, PreparedControlEffectRow,
+ PreparedControlEffectSection, PreparedControlGateProvider, PreparedControlNodeBinding,
+ PreparedControlPoint, PreparedControlPointList, PreparedMaterialPhase, PreparedModel,
+ PreparedTexture, PreparedTextureUsage, PreparedVisual, RgbaImage, StandaloneWearMaterialLoader,
};
-use fparkan_fx::atmosphere::TypedAtmosphere;
+use fparkan_fx::ambient_op3::{
+ evaluate_op3_type0_command, evaluate_op3_type1_command, evaluate_op3_type2_command,
+};
+use fparkan_fx::atmosphere::{AtmosphereFrame, TypedAtmosphere};
use fparkan_fx::environment::{
- decode_env_lightning_fxid, Camera as FxCamera, EnvironmentFrame, EnvironmentPrimitive,
- EnvironmentSystem, PrecipitationKind, ScreenBillboard,
+ decode_env_lightning_fxid, evaluate_op1_point_light_with_basis, Camera as FxCamera,
+ EnvironmentFrame, EnvironmentPrimitive, EnvironmentSystem, PrecipitationKind, ScreenBillboard,
+};
+use fparkan_fx::fx_visual::{evaluate_f_gener_ball, evaluate_f_gener_ball_mesh};
+use fparkan_fx::op7::{
+ evaluate_op7_emitter, Op7EmitterState, Op7RenderContext, Op7VisibilityProfile,
};
use fparkan_fx::shadow::{
ShadowCamera, ShadowCaster, ShadowFrame, ShadowLight, ShadowLodSettings, ShadowPageCache,
@@ -26,16 +35,21 @@ use fparkan_fx::sky::{
sprite_half_size_pixels, SkyFrame, SkyMaterials, SkyMesh, SkyPassKind, SkySpriteFrame,
SkySystem, SunVisibility, SKY_FLARE_ROWS,
};
+use fparkan_fx::smoke::{evaluate_op8_emitter, Op8DetailThresholdRange, SmokeEmitterState};
+use fparkan_fx::{advance_native_fx_random_state, decode_fxid, FxCommand, FxDocument, FxOpcode};
+#[cfg(test)]
+use fparkan_material::{MaterialAnimationBlock, MaterialKey};
use fparkan_msh::{
node38_fallback_hierarchy, node38_sampled_hierarchy, node38_sampled_hierarchy_at_times,
- ModelAsset,
+ selected_slot, Group, Lod, ModelAsset, NodeId, SlotId,
};
use fparkan_path::ResourceName;
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
-use fparkan_prototype::{unit_component_tree, UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO};
+use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO};
use fparkan_render::{
- LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthMode,
- LegacyIron3dEulerTransform, LegacyPipelineState, RawCameraTransform,
+ legacy_msh_portal_alpha, LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection,
+ LegacyDepthBias, LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState,
+ RawCameraTransform,
};
use fparkan_render_vulkan::{
node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
@@ -46,22 +60,27 @@ use fparkan_render_vulkan::{
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount,
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount,
- VulkanDynamicDrawRange, VulkanNodePose, VulkanPointLight, VulkanSmokeFrameOutcome,
- VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticDrawRange,
- VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanStaticTextureMip,
- VulkanStaticVertex,
+ VulkanDynamicDrawRange, VulkanNodePose, VulkanPointLight, VulkanReadbackArtifact,
+ VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
+ VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh,
+ VulkanStaticTexture, VulkanStaticTextureMip, VulkanStaticVertex,
};
use fparkan_runtime::{
create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets,
- loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, MissionMode,
+ loaded_mission_object_drafts, loaded_terrain, query_building_segment_world,
+ BasementPlacementMode, BuildingMeshNode, EngineServices, MissionAssets, MissionMode,
MissionObjectDraft, MissionRequest,
};
+#[cfg(test)]
+use fparkan_terrain::BasementFoundationFragment;
use fparkan_terrain::{
+ build_basement_foundations, BasementFootprint, BasementFoundation, BasementPlacement,
FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld,
};
use fparkan_vfs::DirectoryVfs;
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
+use std::io::Write;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -153,11 +172,12 @@ fn run(args: &[String]) -> Result<String, String> {
environment,
audio,
args.frames,
- args.control_clock_ms,
&args.mission,
loaded.object_count,
args.readback_out.as_deref(),
+ args.readback_sequence_out.as_deref(),
args.validation,
+ args.control_clock_ms,
StartupTimings {
mission_assets: mission_assets_duration,
terrain_materials: terrain_materials_duration,
@@ -332,6 +352,11 @@ impl FreeFlightCamera {
if !self.ground_clearance.is_finite() || self.ground_clearance < 0.0 {
return Err("free-flight ground clearance must be finite and nonnegative".to_string());
}
+ if let Some([min, max]) = terrain.surface_bounds() {
+ for axis in 0..2 {
+ self.position[axis] = self.position[axis].clamp(min[axis], max[axis]);
+ }
+ }
let Some(ground_height) = terrain
.height_at([self.position[0], self.position[1]])
.map_err(|error| format!("free-flight terrain height query: {error}"))?
@@ -348,7 +373,6 @@ impl FreeFlightCamera {
self.position[2] = self.position[2].max(minimum_z);
Ok(())
}
-
fn forward(self) -> [f32; 3] {
let (sin_yaw, cos_yaw) = self.yaw.sin_cos();
let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
@@ -617,6 +641,10 @@ impl PreviewCameraFrame {
const ENVIRONMENT_SPRITE_ROWS: [usize; 2] = [3, 4];
const ENVIRONMENT_FLARE_SLOTS: usize = 12;
const ENVIRONMENT_PARTICLE_SLOTS: usize = 1_000;
+// shortcut: Effect.dll derives this from projected sphere size and a render-context
+// quality table. Category 0 supplies a highest-detail mesh until that exact
+// world category is available; this does not reproduce native LOD selection.
+const AMBIENT_EFFECT_NATIVE_CATEGORY: u8 = 0;
#[derive(Clone, Copy, Debug)]
struct EnvironmentGpuRange {
@@ -626,6 +654,200 @@ struct EnvironmentGpuRange {
material_index: usize,
}
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum AmbientEffectKind {
+ Op3Type0,
+ Op3Type1,
+ Op3Type2,
+ GenerBall,
+ SmokePool,
+ Op7ParticlePool,
+}
+
+#[derive(Clone, Debug)]
+struct AmbientEffectGpuSlot {
+ kind: AmbientEffectKind,
+ time_mode: u32,
+ header_flags: u32,
+ random_instance_key: ControlEffectInstanceKey,
+ random_seed: u32,
+ phase_jitter: f32,
+ random_shift: [f32; 3],
+ owner_object_index: usize,
+ owner_component_index: usize,
+ owner_node_index: usize,
+ owner_component_is_mounted: bool,
+ root_transform: LegacyIron3dEulerTransform,
+ owner_scale: [f32; 3],
+ mount_pose: Option<VulkanNodePose>,
+ local_from_effect: [[f32; 4]; 4],
+ control_source: String,
+ control_table_index: usize,
+ control_row_index: usize,
+ control_point_indices: [u32; 3],
+ control_point_owners: [i32; 3],
+ command: FxCommand,
+ command_index: usize,
+ duration_seconds: f32,
+ world_from_effect: [[f32; 4]; 4],
+ range: EnvironmentGpuRange,
+ additional_particle_ranges: Vec<EnvironmentGpuRange>,
+ material_animation_index: usize,
+ additional_particle_material_animation_indices: Vec<usize>,
+ smoke_emitter_state: Option<SmokeEmitterState>,
+ op7_emitter_state: Option<Op7EmitterState>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct AmbientEffectMeshPrimitive {
+ vertex_start: usize,
+ vertex_count: usize,
+ index_start: usize,
+ index_count: usize,
+}
+
+#[derive(Clone, Debug)]
+struct ControlPointLightSlot {
+ time_mode: u32,
+ header_flags: u32,
+ random_instance_key: ControlEffectInstanceKey,
+ random_seed: u32,
+ phase_jitter: f32,
+ random_shift: [f32; 3],
+ duration_seconds: f32,
+ owner_object_index: usize,
+ owner_component_index: usize,
+ owner_node_index: usize,
+ owner_component_is_mounted: bool,
+ root_transform: LegacyIron3dEulerTransform,
+ owner_scale: [f32; 3],
+ mount_pose: Option<VulkanNodePose>,
+ local_from_effect: [[f32; 4]; 4],
+ world_from_effect: [[f32; 4]; 4],
+ command_index: usize,
+ command: FxCommand,
+}
+
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+struct ControlEffectInstanceKey {
+ object_index: usize,
+ component_index: usize,
+ table_index: usize,
+ row_index: usize,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+struct ControlEffectRandomSample {
+ phase_jitter: f32,
+ accumulated_shift: [f32; 3],
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum SceneSampleId {
+ Live(u64),
+ Replay(u64),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct FrameClockSample {
+ id: SceneSampleId,
+ time_ms: u32,
+ delta_seconds: f32,
+}
+
+#[derive(Clone, Debug)]
+struct NativeFxRandom16 {
+ state: u32,
+}
+
+impl NativeFxRandom16 {
+ fn new(seed: u32) -> Self {
+ Self {
+ state: if seed == 0 { 0xA341_316C } else { seed },
+ }
+ }
+
+ fn next_u16(&mut self) -> u16 {
+ advance_native_fx_random_state(&mut self.state)
+ }
+
+ fn symmetric_sample(&mut self, amplitude: f32) -> f32 {
+ (f32::from(self.next_u16()) / 65_536.0 - 0.5) * amplitude
+ }
+}
+
+#[derive(Clone, Debug)]
+struct ControlEffectRandomState {
+ header_flags: u32,
+ phase_jitter_amplitude: f32,
+ random_shift_amplitude: [f32; 3],
+ phase_and_z_rng: NativeFxRandom16,
+ xy_rng: NativeFxRandom16,
+ sample: ControlEffectRandomSample,
+ last_update_id: Option<SceneSampleId>,
+ last_update_clock_ms: Option<u32>,
+}
+
+impl ControlEffectRandomState {
+ fn new(seed: u32, header_flags: u32, phase_jitter: f32, random_shift: [f32; 3]) -> Self {
+ Self {
+ header_flags,
+ phase_jitter_amplitude: phase_jitter,
+ random_shift_amplitude: random_shift,
+ phase_and_z_rng: NativeFxRandom16::new(seed ^ 0xA511_E9B3),
+ xy_rng: NativeFxRandom16::new(seed ^ 0x63D8_3595),
+ sample: ControlEffectRandomSample::default(),
+ last_update_id: None,
+ last_update_clock_ms: None,
+ }
+ }
+
+ fn advance(
+ &mut self,
+ sample_id: SceneSampleId,
+ control_clock_ms: u32,
+ initial_phase: f32,
+ ) -> ControlEffectRandomSample {
+ // Native updates consume process-global RNG streams. The preview uses
+ // a deterministic per-Control-row stream; sibling slots share one
+ // sample and cadence, but native cross-row RNG ordering remains open.
+ if self.last_update_id == Some(sample_id) {
+ return self.sample;
+ }
+ if self.header_flags & 1 != 0 && initial_phase != -1.0 {
+ self.sample.phase_jitter = self
+ .phase_and_z_rng
+ .symmetric_sample(self.phase_jitter_amplitude);
+ } else {
+ self.sample.phase_jitter = 0.0;
+ }
+ if self.header_flags & 8 != 0 {
+ let shift_z = self
+ .phase_and_z_rng
+ .symmetric_sample(self.random_shift_amplitude[2]);
+ let shift_y = self.xy_rng.symmetric_sample(self.random_shift_amplitude[1]);
+ let shift_x = self.xy_rng.symmetric_sample(self.random_shift_amplitude[0]);
+ self.sample.accumulated_shift[0] += shift_x;
+ self.sample.accumulated_shift[1] += shift_y;
+ self.sample.accumulated_shift[2] += shift_z;
+ }
+ self.last_update_id = Some(sample_id);
+ self.last_update_clock_ms = Some(control_clock_ms);
+ self.sample
+ }
+}
+
+#[derive(Clone, Debug)]
+struct PreparedEffectMaterial {
+ document: Mat0Document,
+ phase_material_indices: Vec<usize>,
+ phase_uv_transforms: Vec<[f32; 4]>,
+ phase_native_flag_40: Vec<bool>,
+ phase_directional_rgb: Vec<[f32; 3]>,
+ phase_additive_rgb: Vec<[f32; 3]>,
+ initial_pipeline_category: u8,
+}
+
#[derive(Clone, Debug)]
struct EnvironmentMaterialAsset {
texture: VulkanStaticTexture,
@@ -715,6 +937,23 @@ impl MaterialPhaseBinding {
})
}
+ fn sample_normalized(
+ &self,
+ material_index: usize,
+ normalized_phase: f32,
+ ) -> Result<MaterialPhaseSample, String> {
+ sample_material_normalized_phase(
+ &self.document,
+ self.animation_block_index,
+ normalized_phase,
+ )
+ .map_err(|error| {
+ format!(
+ "sample MAT0 material {material_index} at normalized phase {normalized_phase}: {error}"
+ )
+ })
+ }
+
fn page_uv_transform(&self, phase_index: usize) -> [f32; 4] {
self.phase_uv_transforms
.get(phase_index)
@@ -740,11 +979,14 @@ impl MaterialPhaseBinding {
struct MaterialVariantSet {
phase_material_indices: Vec<usize>,
range_indices: Vec<usize>,
+ pipeline_category: u8,
}
#[derive(Clone, Debug)]
struct MaterialAnimationBinding {
initial_phase_index: usize,
+ normalized_phase_override: Option<f32>,
+ ambient_fx_material_alpha: bool,
variants: Vec<MaterialVariantSet>,
base: MaterialPhaseBinding,
detail: Option<MaterialPhaseBinding>,
@@ -753,10 +995,39 @@ struct MaterialAnimationBinding {
stage_textures: Option<MaterialStageTextures>,
}
-#[derive(Clone, Copy, Debug, PartialEq)]
+#[derive(Clone, Copy, Debug)]
struct ActiveMaterialState {
material_index: usize,
+ phase_index: usize,
diffuse_alpha: f32,
+ additive_rgb: [f32; 3],
+}
+
+fn ambient_effect_material_alpha(is_ambient_effect: bool, sampled_opacity: f32) -> f32 {
+ if is_ambient_effect {
+ 1.0
+ } else {
+ sampled_opacity
+ }
+}
+
+fn sample_material_animation_base(
+ binding: &mut MaterialAnimationBinding,
+ clock_ms: u32,
+) -> Result<MaterialPhaseSample, String> {
+ match binding.normalized_phase_override {
+ Some(normalized_phase) => binding
+ .base
+ .sample_normalized(binding.initial_phase_index, normalized_phase),
+ None => binding.base.sample(binding.initial_phase_index, clock_ms),
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct AmbientEffectGeometryUpdate {
+ range_index: usize,
+ index_count: u32,
+ command_rgb: [f32; 3],
}
fn selected_material_phase(
@@ -772,14 +1043,14 @@ fn selected_material_phase(
Ok((material_index, pipeline_category))
}
-#[derive(Clone, Copy, Debug, PartialEq)]
+#[derive(Clone, Copy, Debug)]
struct MaterialRangeSelection {
range_index: usize,
material_index: usize,
pipeline_category: u8,
}
-#[derive(Clone, Copy, Debug, PartialEq)]
+#[derive(Clone, Copy, Debug)]
struct MaterialPropertiesUpdate {
material_index: usize,
diffuse_alpha: f32,
@@ -795,14 +1066,14 @@ struct MaterialPropertiesUpdate {
uv_transforms: [[f32; 4]; 4],
}
-#[derive(Clone, Copy, Debug, PartialEq)]
+#[derive(Clone, Copy, Debug)]
struct MaterialStagePhaseUpdate {
binding_index: usize,
material_index: usize,
phases: [usize; 3],
}
-#[derive(Clone, Debug, Default, PartialEq)]
+#[derive(Clone, Debug, Default)]
struct MaterialFrameUpdate {
active_materials: HashMap<usize, ActiveMaterialState>,
range_selections: Vec<MaterialRangeSelection>,
@@ -817,7 +1088,11 @@ fn sample_material_animations(
) -> Result<MaterialFrameUpdate, String> {
let mut update = MaterialFrameUpdate::default();
for (binding_index, binding) in bindings.iter_mut().enumerate() {
- let base_sample = binding.base.sample(binding.initial_phase_index, clock_ms)?;
+ let base_sample = sample_material_animation_base(binding, clock_ms)?;
+ let material_alpha = ambient_effect_material_alpha(
+ binding.ambient_fx_material_alpha,
+ base_sample.coefficients.opacity,
+ );
let detail_sample = binding
.detail
.as_mut()
@@ -834,7 +1109,7 @@ fn sample_material_animations(
.map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms))
.transpose()?;
let mut updated_materials = HashSet::new();
- for variant in &binding.variants {
+ for variant in &mut binding.variants {
let (material_index, pipeline_category) =
selected_material_phase(variant, base_sample.phase_index, |index| {
materials
@@ -846,7 +1121,9 @@ fn sample_material_animations(
range_index,
ActiveMaterialState {
material_index,
- diffuse_alpha: base_sample.coefficients.opacity,
+ phase_index: base_sample.phase_index,
+ diffuse_alpha: material_alpha,
+ additive_rgb: base_sample.coefficients.additive_rgb,
},
);
update.range_selections.push(MaterialRangeSelection {
@@ -855,6 +1132,7 @@ fn sample_material_animations(
pipeline_category,
});
}
+ variant.pipeline_category = pipeline_category;
if !updated_materials.insert(material_index) {
continue;
}
@@ -866,9 +1144,7 @@ fn sample_material_animations(
let (base_directional, base_additive, overlay_directional, overlay_additive) =
if material.lightmap_mode {
// Terrain mode 3 samples the lightmap in the secondary
- // stage and uses the authored directional RGB as its
- // multiplicative lightmap coefficient. It has no normal
- // lighting or overlay stage.
+ // stage and uses the authored directional RGB as additive.
([0.0; 3], base_coefficients.directional_rgb, None, None)
} else {
(
@@ -878,16 +1154,27 @@ fn sample_material_animations(
overlay_coefficients.map(|coefficients| coefficients.additive_rgb),
)
};
- if !material.lightmap_mode && binding.stage_textures.is_some() {
- update.stage_phases.push(MaterialStagePhaseUpdate {
- binding_index,
- material_index,
- phases: [
+
+ if !material.lightmap_mode {
+ if let Some(stage_textures) = binding.stage_textures.as_ref() {
+ let stage_phases = [
detail_sample.map_or(0, |sample| sample.phase_index),
overlay_sample.map_or(0, |sample| sample.phase_index),
overlay_detail_sample.map_or(0, |sample| sample.phase_index),
- ],
- });
+ ];
+ if stage_textures
+ .applied_material_phases
+ .get(&material_index)
+ .copied()
+ != Some(stage_phases)
+ {
+ update.stage_phases.push(MaterialStagePhaseUpdate {
+ binding_index,
+ material_index,
+ phases: stage_phases,
+ });
+ }
+ }
}
let overlay_uv = overlay_sample
.map(|sample| {
@@ -937,7 +1224,7 @@ fn sample_material_animations(
.checked_page_uv_transform(base_sample.phase_index)?;
update.properties.push(MaterialPropertiesUpdate {
material_index,
- diffuse_alpha: base_coefficients.opacity.clamp(0.0, 1.0),
+ diffuse_alpha: material_alpha.clamp(0.0, 1.0),
overlay_alpha: overlay_coefficients
.map_or(0.0, |coefficients| coefficients.opacity.clamp(0.0, 1.0)),
base_directional,
@@ -1048,6 +1335,31 @@ fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) -
diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0
}
+fn native_msh_diffuse_alpha_override(
+ mesh: &VulkanStaticMesh,
+ range_index: usize,
+ type16_present: bool,
+ eye_world: [f32; 3],
+ horizontal_fov: f32,
+) -> Result<Option<f32>, String> {
+ let range = mesh
+ .draw_ranges
+ .get(range_index)
+ .ok_or_else(|| "portal draw range is out of bounds".to_string())?;
+ if type16_present || range.batch_flags & 0x0008 == 0 {
+ return Ok(None);
+ }
+ let distance = if range.batch_flags & 0x0050 == 0x0040 {
+ 0.0
+ } else {
+ // shortcut: use the first indexed vertex; replace with native CPU clipping selection when clipped-portal captures are available.
+ first_indexed_vertex_distance(mesh, range_index, eye_world)?
+ };
+ legacy_msh_portal_alpha(range.batch_flags, distance, horizontal_fov)
+ .map(Some)
+ .ok_or_else(|| "portal distance or field of view is invalid".to_string())
+}
+
fn native_msh_handedness(transform: LegacyIron3dEulerTransform, scale: [f32; 3]) -> i8 {
let Some(origin) = transform.try_transform_scaled_point([0.0; 3], scale) else {
return 0;
@@ -1358,6 +1670,15 @@ fn world_pipeline_state(blend: LegacyBlendMode, depth: LegacyDepthMode) -> Legac
}
}
+fn projected_shadow_pipeline_state() -> LegacyPipelineState {
+ LegacyPipelineState {
+ blend: LegacyBlendMode::SourceAlpha,
+ depth: LegacyDepthMode::TestReadOnly,
+ depth_bias: LegacyDepthBias::ProjectedDecal,
+ ..LegacyPipelineState::default()
+ }
+}
+
fn environment_vertex(position: [f32; 3], color: [f32; 3], uv: [f32; 2]) -> VulkanStaticVertex {
environment_vertex_with_alpha(position, color, 1.0, uv)
}
@@ -1499,9 +1820,12 @@ fn append_environment_material(
let document = asset.document.clone();
material_animations.push(MaterialAnimationBinding {
initial_phase_index: 0,
+ normalized_phase_override: None,
+ ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices,
range_indices: Vec::new(),
+ pipeline_category: phases[0].pipeline_category,
}],
base: MaterialPhaseBinding {
document,
@@ -1520,6 +1844,132 @@ fn append_environment_material(
Ok(first_material_index)
}
+fn prepare_effect_material(
+ root: &Path,
+ archive: &str,
+ name: &str,
+ materials: &mut Vec<VulkanStaticMaterial>,
+ cache: &mut HashMap<String, PreparedEffectMaterial>,
+) -> Result<PreparedEffectMaterial, String> {
+ let key = format!("{archive}/{name}");
+ if let Some(prepared) = cache.get(&key) {
+ return Ok(prepared.clone());
+ }
+ let (first_mips, first_phase, document) =
+ load_material_archive_name_texture_rgba8_and_phase_with_document_from_root(
+ root, archive, name, 0,
+ )
+ .map_err(|error| format!("resolve FX MAT0 {key}: {error}"))?;
+ if document.phases.is_empty() {
+ return Err(format!("FX MAT0 {key} has no phases"));
+ }
+
+ let mut phase_material_indices = Vec::with_capacity(document.phases.len());
+ let mut phase_uv_transforms = Vec::with_capacity(document.phases.len());
+ let mut phase_native_flag_40 = Vec::with_capacity(document.phases.len());
+ let mut phase_directional_rgb = Vec::with_capacity(document.phases.len());
+ let mut phase_additive_rgb = Vec::with_capacity(document.phases.len());
+ for phase_index in 0..document.phases.len() {
+ let (mips, phase) = if phase_index == 0 {
+ (first_mips.clone(), first_phase)
+ } else {
+ let phase_index_u16 = u16::try_from(phase_index)
+ .map_err(|_| format!("FX MAT0 {key} phase index exceeds u16"))?;
+ let (mips, phase, _document) =
+ load_material_archive_name_texture_rgba8_and_phase_with_document_from_root(
+ root,
+ archive,
+ name,
+ phase_index_u16,
+ )
+ .map_err(|error| format!("resolve FX MAT0 {key} phase {phase_index}: {error}"))?;
+ (mips, phase)
+ };
+ let texture = vulkan_texture_from_rgba_mips(mips, "FX material")?;
+ let index = materials.len();
+ let material_index = u16::try_from(index).map_err(|_| {
+ "FX effects exceed the available 16-bit material selector space".to_string()
+ })?;
+ materials.push(VulkanStaticMaterial {
+ material_index,
+ pipeline_category: phase.pipeline_category,
+ texture,
+ directional_rgb: phase.directional_rgb,
+ additive_rgb: phase.additive_rgb,
+ uv_transform: phase.page_uv_transform,
+ detail_texture: Some(solid_environment_texture([128, 128, 128, 255])),
+ overlay_texture: None,
+ overlay_detail_texture: None,
+ // FX descriptor opacity is carried by each frame's vertex alpha.
+ // Keep MAT0 alpha at one so it does not multiply the opacity a
+ // second time.
+ diffuse_alpha: 1.0,
+ overlay_diffuse_alpha: None,
+ overlay_directional_rgb: None,
+ overlay_additive_rgb: None,
+ specular_rgb: phase.specular_rgb,
+ specular_power: phase.power,
+ overlay_specular_rgb: None,
+ overlay_specular_power: None,
+ detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
+ overlay_uv_transform: None,
+ overlay_detail_uv_transform: None,
+ unlit: true,
+ sky_nebula_stars: false,
+ lightmap_mode: false,
+ sky_far_depth: false,
+ screen_tl: false,
+ });
+ phase_material_indices.push(index);
+ phase_uv_transforms.push(phase.page_uv_transform);
+ phase_native_flag_40.push(phase.native_flag_40);
+ phase_directional_rgb.push(phase.directional_rgb);
+ phase_additive_rgb.push(phase.additive_rgb);
+ }
+ let prepared = PreparedEffectMaterial {
+ initial_pipeline_category: materials[phase_material_indices[0]].pipeline_category,
+ document,
+ phase_material_indices,
+ phase_uv_transforms,
+ phase_native_flag_40,
+ phase_directional_rgb,
+ phase_additive_rgb,
+ };
+ cache.insert(key, prepared.clone());
+ Ok(prepared)
+}
+
+fn append_effect_material_animation(
+ prepared: &PreparedEffectMaterial,
+ range_index: usize,
+ bindings: &mut Vec<MaterialAnimationBinding>,
+) -> usize {
+ let binding_index = bindings.len();
+ bindings.push(MaterialAnimationBinding {
+ initial_phase_index: 0,
+ normalized_phase_override: None,
+ ambient_fx_material_alpha: true,
+ variants: vec![MaterialVariantSet {
+ phase_material_indices: prepared.phase_material_indices.clone(),
+ range_indices: vec![range_index],
+ pipeline_category: prepared.initial_pipeline_category,
+ }],
+ base: MaterialPhaseBinding {
+ document: prepared.document.clone(),
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: u32::try_from(binding_index).unwrap_or(u32::MAX),
+ frozen_phase_index: None,
+ phase_uv_transforms: prepared.phase_uv_transforms.clone(),
+ },
+ detail: None,
+ overlay: None,
+ overlay_detail: None,
+ stage_textures: None,
+ });
+ binding_index
+}
+
fn append_environment_gradient_material(
materials: &mut Vec<VulkanStaticMaterial>,
) -> Result<usize, String> {
@@ -1636,53 +2086,58 @@ fn terrain_shadow_receivers(
) -> Result<Vec<ShadowTriangle>, String> {
let assignments = terrain
.face_lod_assignments()
- .map_err(|error| error.to_string())?;
- let mut receivers = Vec::with_capacity(
- assignments
- .iter()
- .filter(|assignment| assignment.lod == TerrainLod::Lod0)
- .count(),
- );
- for assignment in assignments {
- if assignment.lod != TerrainLod::Lod0 {
- continue;
- }
- let face = terrain.faces.get(assignment.face_index).ok_or_else(|| {
- format!(
- "terrain LOD0 references unknown face {}",
- assignment.face_index
- )
- })?;
- let positions = [
- terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[0])?,
- terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[1])?,
- terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[2])?,
- ];
- receivers.push(shadow_triangle(positions, face.flags.0));
- }
- Ok(receivers)
+ .map_err(|error| format!("select terrain LOD0 shadow receivers: {error}"))?;
+ assignments
+ .into_iter()
+ .filter(|assignment| assignment.lod == TerrainLod::Lod0)
+ .map(|assignment| {
+ let face = terrain.faces.get(assignment.face_index).ok_or_else(|| {
+ format!(
+ "terrain LOD assignment references missing face {}",
+ assignment.face_index
+ )
+ })?;
+ let positions = source_terrain_face_positions(terrain, assignment.face_index)?;
+ if !positions.iter().flatten().all(|value| value.is_finite()) {
+ return Err("terrain shadow receiver contains a non-finite position".to_string());
+ }
+ Ok(shadow_triangle(positions, face.flags.0))
+ })
+ .collect()
}
-fn terrain_shadow_vertex_position(
+fn source_terrain_face_positions(
terrain: &fparkan_terrain_format::LandMeshDocument,
face_index: usize,
- vertex_index: u16,
-) -> Result<[f32; 3], String> {
- let position = terrain
- .positions
- .get(usize::from(vertex_index))
- .copied()
- .ok_or_else(|| {
- format!(
- "terrain LOD0 shadow face {face_index} references unknown vertex {vertex_index}"
- )
- })?;
- if !position.iter().all(|value| value.is_finite()) {
- return Err(format!(
- "terrain LOD0 shadow face {face_index} has a non-finite vertex {vertex_index}"
- ));
- }
- Ok(position)
+) -> Result<[[f32; 3]; 3], String> {
+ let face = terrain
+ .faces
+ .get(face_index)
+ .ok_or_else(|| format!("terrain LOD assignment references missing face {face_index}"))?;
+ let [first, second, third] = face.vertices;
+ Ok([
+ terrain
+ .positions
+ .get(usize::from(first))
+ .copied()
+ .ok_or_else(|| {
+ format!("terrain face {face_index} references missing vertex {first}")
+ })?,
+ terrain
+ .positions
+ .get(usize::from(second))
+ .copied()
+ .ok_or_else(|| {
+ format!("terrain face {face_index} references missing vertex {second}")
+ })?,
+ terrain
+ .positions
+ .get(usize::from(third))
+ .copied()
+ .ok_or_else(|| {
+ format!("terrain face {face_index} references missing vertex {third}")
+ })?,
+ ])
}
fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle> {
@@ -1709,46 +2164,205 @@ fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle>
.collect()
}
-fn expand_shadow_sphere_to_triangles(
- sphere: &mut ShadowSphere,
+fn static_mesh_shadow_triangles_in_index_range(
+ mesh: &VulkanStaticMesh,
+ first_index: usize,
+ index_count: usize,
+) -> Result<Vec<ShadowTriangle>, String> {
+ if index_count % 3 != 0 {
+ return Err(format!(
+ "dynamic shadow index range {first_index}..{} is not triangle-aligned",
+ first_index.saturating_add(index_count)
+ ));
+ }
+ let end = first_index
+ .checked_add(index_count)
+ .filter(|end| *end <= mesh.indices.len())
+ .ok_or_else(|| {
+ format!(
+ "dynamic shadow index range {first_index}..{} is out of bounds",
+ first_index.saturating_add(index_count)
+ )
+ })?;
+ mesh.indices[first_index..end]
+ .chunks_exact(3)
+ .map(|triangle| {
+ let positions =
+ triangle
+ .iter()
+ .map(|index| {
+ mesh.vertices
+ .get(usize::try_from(*index).map_err(|_| {
+ "dynamic shadow vertex index exceeds usize".to_string()
+ })?)
+ .map(|vertex| vertex.position)
+ .ok_or_else(|| {
+ format!("dynamic shadow vertex index {index} is out of bounds")
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ let positions: [[f32; 3]; 3] = positions
+ .try_into()
+ .map_err(|_| "dynamic shadow triangle did not have three vertices".to_string())?;
+ if !positions.iter().flatten().all(|value| value.is_finite()) {
+ return Err("dynamic shadow triangle contains a non-finite position".to_string());
+ }
+ Ok(shadow_triangle(positions, 0))
+ })
+ .collect()
+}
+
+fn refresh_dynamic_shadow_casters(
+ casters: &mut [ShadowCaster],
+ dynamic_casters: &[DynamicShadowCaster],
+ mesh: &VulkanStaticMesh,
+ pose_buffers: &[Vec<Option<NodePoseBuffer>>],
+ mount_poses: &[Vec<Option<VulkanNodePose>>],
+) -> Result<(), String> {
+ for dynamic in dynamic_casters {
+ let caster = casters.get_mut(dynamic.caster_index).ok_or_else(|| {
+ format!(
+ "dynamic shadow caster {} is outside the aggregate caster table",
+ dynamic.caster_index
+ )
+ })?;
+ let mut triangles = Vec::new();
+ for &range_index in &dynamic.component_draw_range_indices {
+ let range = mesh.draw_ranges.get(range_index).ok_or_else(|| {
+ format!(
+ "dynamic shadow caster {} references missing draw range {range_index}",
+ dynamic.caster_index
+ )
+ })?;
+ triangles.extend(static_mesh_shadow_triangles_in_index_range(
+ mesh,
+ usize::try_from(range.first_index)
+ .map_err(|_| "dynamic shadow draw range index exceeds usize".to_string())?,
+ usize::try_from(range.index_count)
+ .map_err(|_| "dynamic shadow draw range count exceeds usize".to_string())?,
+ )?);
+ }
+ if triangles.is_empty() {
+ return Err(format!(
+ "dynamic shadow caster {} lost its component triangles",
+ dynamic.caster_index
+ ));
+ }
+ let component_spheres = dynamic
+ .component_spheres
+ .iter()
+ .filter_map(|source| {
+ let mount_pose = if source.mounted {
+ mount_poses
+ .get(dynamic.object_index)
+ .and_then(|items| items.get(source.component_index))
+ .copied()
+ .flatten()
+ } else {
+ None
+ };
+ if source.mounted && mount_pose.is_none() {
+ return None;
+ }
+ let node1_pose = if source.mounted {
+ pose_buffers
+ .get(dynamic.object_index)
+ .and_then(|items| items.get(source.component_index))
+ .and_then(Option::as_ref)
+ .and_then(|hierarchy| {
+ node38_pose_relative_to_root_from_hierarchy(hierarchy, 1)
+ })
+ } else {
+ None
+ };
+ mounted_shadow_sphere(source.center, source.radius, mount_pose, node1_pose)
+ })
+ .collect::<Vec<_>>();
+ if component_spheres.len() != dynamic.component_spheres.len() {
+ return Err(format!(
+ "dynamic shadow caster {} lost one or more component sphere poses",
+ dynamic.caster_index
+ ));
+ }
+ let mut refreshed = aggregate_shadow_caster(
+ caster.id,
+ caster.native_kind,
+ dynamic.root_transform,
+ dynamic.owner_scale,
+ triangles,
+ &component_spheres,
+ )
+ .ok_or_else(|| {
+ format!(
+ "dynamic shadow caster {} could not be rebuilt from current component poses",
+ dynamic.caster_index
+ )
+ })?;
+ // shortcut: animated native sphere parity is unverified. Rebuild from
+ // the startup native bound, the current native bound, and current
+ // triangles so the result is conservative and independent of history.
+ refreshed.sphere = shadow_sphere_enclosing_current_geometry(
+ dynamic.initial_sphere,
+ refreshed.sphere,
+ &refreshed.triangles,
+ )?;
+ *caster = refreshed;
+ }
+ Ok(())
+}
+
+fn shadow_sphere_enclosing_current_geometry(
+ baseline: ShadowSphere,
+ current_native: ShadowSphere,
triangles: &[ShadowTriangle],
-) -> Result<bool, String> {
+) -> Result<ShadowSphere, String> {
+ let mut sphere = baseline;
if !sphere.radius.is_finite()
|| sphere.radius < 0.0
|| !sphere.center.iter().all(|value| value.is_finite())
+ || !current_native.radius.is_finite()
+ || current_native.radius < 0.0
+ || !current_native.center.iter().all(|value| value.is_finite())
{
return Err("animated shadow caster has an invalid bounding sphere".to_string());
}
- let mut changed = false;
+
+ let offset = sub3(current_native.center, sphere.center);
+ let distance = offset[0].hypot(offset[1]).hypot(offset[2]);
+ if !distance.is_finite() {
+ return Err("animated shadow sphere distance is non-finite".to_string());
+ }
+ let required = distance + current_native.radius;
+ if !required.is_finite() {
+ return Err("animated shadow sphere radius is non-finite".to_string());
+ }
+ if current_native.radius >= distance + sphere.radius {
+ sphere = current_native;
+ } else if required > sphere.radius {
+ let next_radius = sphere.radius + 0.5 * (required - sphere.radius);
+ let shift = scale3(offset, (next_radius - sphere.radius) / distance);
+ sphere.center = add3(sphere.center, shift);
+ sphere.radius = next_radius;
+ }
+
for point in triangles.iter().flat_map(|triangle| triangle.positions) {
if !point.iter().all(|value| value.is_finite()) {
return Err("animated shadow triangle has a non-finite position".to_string());
}
let offset = sub3(point, sphere.center);
- let distance = length3(offset)
- .ok_or_else(|| "animated shadow point distance is non-finite".to_string())?;
+ let distance = offset[0].hypot(offset[1]).hypot(offset[2]);
+ if !distance.is_finite() {
+ return Err("animated shadow point distance is non-finite".to_string());
+ }
if distance <= sphere.radius {
continue;
}
- let next_radius = 0.5 * (sphere.radius + distance);
- let center_shift = if distance > f32::EPSILON {
- scale3(offset, (next_radius - sphere.radius) / distance)
- } else {
- [0.0; 3]
- };
+ let next_radius = sphere.radius + 0.5 * (distance - sphere.radius);
+ let center_shift = scale3(offset, (next_radius - sphere.radius) / distance);
sphere.center = add3(sphere.center, center_shift);
sphere.radius = next_radius;
- changed = true;
}
- Ok(changed)
-}
-fn shadow_sphere_enclosing_triangles(
- baseline: ShadowSphere,
- triangles: &[ShadowTriangle],
-) -> Result<ShadowSphere, String> {
- let mut sphere = baseline;
- expand_shadow_sphere_to_triangles(&mut sphere, triangles)?;
Ok(sphere)
}
@@ -1858,6 +2472,7 @@ fn shadow_lights(
sky_frame: Option<&SkyFrame<'_>>,
environment_frame: Option<&EnvironmentFrame>,
renderer: &VulkanSmokeRenderer,
+ control_point_lights: &[(VulkanPointLight, u32)],
) -> Vec<ShadowLight> {
let mut lights = Vec::new();
if let Some(sky_frame) = sky_frame {
@@ -1919,6 +2534,18 @@ fn shadow_lights(
flags: 0,
});
}
+ lights.extend(
+ control_point_lights
+ .iter()
+ .map(|(light, flags)| ShadowLight::Point {
+ position: light.position,
+ rgb: light.rgb,
+ range: light.range,
+ coefficients: light.coefficients,
+ active: light.active,
+ flags: *flags,
+ }),
+ );
lights
}
@@ -2172,6 +2799,33 @@ fn reorder_draw_ranges(
Ok(remap)
}
+fn remap_dynamic_shadow_caster_draw_ranges(
+ dynamic_casters: &mut [DynamicShadowCaster],
+ range_remap: &[usize],
+) -> Result<(), String> {
+ let remapped = dynamic_casters
+ .iter()
+ .map(|dynamic| {
+ dynamic
+ .component_draw_range_indices
+ .iter()
+ .map(|&range_index| {
+ range_remap.get(range_index).copied().ok_or_else(|| {
+ format!(
+ "dynamic shadow caster {} references missing pre-reorder draw range {range_index}",
+ dynamic.caster_index
+ )
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ for (dynamic, ranges) in dynamic_casters.iter_mut().zip(remapped) {
+ dynamic.component_draw_range_indices = ranges;
+ }
+ Ok(())
+}
+
fn quad_indices(slot_count: usize) -> Result<Vec<u32>, String> {
let mut indices = Vec::with_capacity(
slot_count
@@ -2268,8 +2922,11 @@ impl EnvironmentGpuScene {
fn new(
mesh: &mut VulkanStaticMesh,
native_handedness_by_range: &mut HashMap<usize, i8>,
+ type16_present_ranges: &mut HashSet<usize>,
materials: &mut Vec<VulkanStaticMaterial>,
preview_material_animations: &mut Vec<MaterialAnimationBinding>,
+ ambient_effect_slots: &mut [AmbientEffectGpuSlot],
+ dynamic_shadow_casters: &mut [DynamicShadowCaster],
sun_occlusion_range_indices: &mut Vec<usize>,
environment_materials: &SkyMaterials,
material_assets: &HashMap<String, EnvironmentMaterialAsset>,
@@ -2695,6 +3352,16 @@ impl EnvironmentGpuScene {
remapped_handedness.insert(new_range_index, handedness);
}
*native_handedness_by_range = remapped_handedness;
+ let mut remapped_type16_present_ranges =
+ HashSet::with_capacity(type16_present_ranges.len());
+ for old_range_index in std::mem::take(type16_present_ranges) {
+ let new_range_index = remap.get(old_range_index).copied().ok_or_else(|| {
+ format!("type-16 MSH draw range remap is missing range {old_range_index}")
+ })?;
+ remapped_type16_present_ranges.insert(new_range_index);
+ }
+ *type16_present_ranges = remapped_type16_present_ranges;
+ remap_dynamic_shadow_caster_draw_ranges(dynamic_shadow_casters, &remap)?;
for layer in &mut scene.sky_layers {
layer.range.range_index = *remap
.get(layer.range.range_index)
@@ -2716,6 +3383,16 @@ impl EnvironmentGpuScene {
.ok_or_else(|| "lightning draw range remap is out of bounds".to_string())?;
remap_material_animation_ranges(&mut scene.material_animations, &remap)?;
remap_material_animation_ranges(preview_material_animations, &remap)?;
+ for slot in ambient_effect_slots {
+ slot.range.range_index = *remap
+ .get(slot.range.range_index)
+ .ok_or_else(|| "ambient FX draw range remap is out of bounds".to_string())?;
+ for range in &mut slot.additional_particle_ranges {
+ range.range_index = *remap.get(range.range_index).ok_or_else(|| {
+ "ambient FX particle draw range remap is out of bounds".to_string()
+ })?;
+ }
+ }
for range_index in sun_occlusion_range_indices {
*range_index = *remap
.get(*range_index)
@@ -3256,8 +3933,8 @@ fn segment_intersects_triangle(
distance.is_finite() && (0.0..=1.0).contains(&distance)
}
-/// Replays the native draw-item callback: one whole indexed batch is keyed by
-/// the camera distance to its first indexed vertex.
+/// Replays Terrain's native packet callback: a draw range is keyed by the
+/// camera distance to its first indexed vertex.
fn first_indexed_vertex_distance(
mesh: &VulkanStaticMesh,
range_index: usize,
@@ -3486,9 +4163,20 @@ fn lightning_quad_vertices(
/// per-frame light/fog block. The sky mesh and weather primitives stay on the
/// same update clock, so camera movement cannot leave environment audio or
/// lighting one frame behind.
-#[derive(Clone, Debug, PartialEq)]
+struct DynamicEnvironment {
+ schedule: TypedAtmosphere,
+ sky: SkySystem,
+ environment: EnvironmentSystem,
+ materials: SkyMaterials,
+ material_assets: HashMap<String, EnvironmentMaterialAsset>,
+ schedule_offset_seconds: f32,
+ time_seconds: f32,
+ fixed_schedule_seconds: Option<f32>,
+ fixed_phase_reported: bool,
+}
+
struct DynamicEnvironmentFrame {
- atmosphere: fparkan_fx::atmosphere::AtmosphereFrame,
+ atmosphere: AtmosphereFrame,
sky_mesh: SkyMesh,
environment_frame: EnvironmentFrame,
absolute_time_seconds: f32,
@@ -3510,26 +4198,28 @@ fn environment_camera_for_preview(
.with_viewport(viewport)
}
-struct DynamicEnvironment {
- schedule: TypedAtmosphere,
- sky: SkySystem,
- environment: EnvironmentSystem,
- materials: SkyMaterials,
- material_assets: HashMap<String, EnvironmentMaterialAsset>,
- schedule_offset_seconds: f32,
- time_seconds: f32,
- fixed_schedule_seconds: Option<f32>,
- fixed_phase_reported: bool,
+struct SceneFrameUpdate {
+ clock: FrameClockSample,
+ camera: PreviewCameraFrame,
+ viewport: [f32; 2],
+ aspect: f32,
+ control_point_lights: Vec<(VulkanPointLight, u32)>,
+ control_vulkan_lights: Vec<VulkanPointLight>,
+ effect_updates: Vec<AmbientEffectGeometryUpdate>,
+ world_sort_keys: Vec<(usize, f32)>,
+ ambient_sort_keys: Vec<(usize, f32)>,
+ material_update: MaterialFrameUpdate,
+ environment_material_update: Option<MaterialFrameUpdate>,
+ environment: Option<DynamicEnvironmentFrame>,
}
impl DynamicEnvironment {
- fn update(
+ fn update_cpu(
&mut self,
dt_seconds: f32,
camera: &PreviewCameraFrame,
aspect: f32,
viewport: [f32; 2],
- renderer: &mut VulkanSmokeRenderer,
mut audio: Option<&mut audio::GameAudio>,
) -> Result<DynamicEnvironmentFrame, String> {
let dt_seconds = if dt_seconds.is_finite() {
@@ -3570,31 +4260,6 @@ impl DynamicEnvironment {
.map_err(|error| format!("update ambient variation: {error}"))?;
}
- let mut uniforms = renderer.frame_uniforms();
- uniforms.clip_from_world = camera.clip_from_world;
- for (target, source) in uniforms
- .directional_lights
- .iter_mut()
- .zip(sky_frame.directional_lights)
- {
- target.direction = source.direction;
- target.rgb = source.color;
- target.coefficients = [0.0, 0.0, 1.0];
- target.active = source.active;
- }
- uniforms.lighting_floor = sky_frame.lighting_floor;
- let fog_rgb = sky_frame
- .screen_gradient(camera.native_sky_heading(), [0.0; 3])
- .color
- .rgba();
- uniforms.fog_color = [fog_rgb[0], fog_rgb[1], fog_rgb[2]];
- uniforms.fog_start = sky_frame.fog.start.max(0.0);
- uniforms.fog_end = sky_frame.fog.end.max(uniforms.fog_start);
- uniforms.camera_position = camera.position;
- renderer
- .set_frame_uniforms(uniforms)
- .map_err(|error| format!("update sky frame uniforms: {error}"))?;
-
let fx_camera = environment_camera_for_preview(camera, aspect, viewport);
let environment_frame = self.environment.update_atmosphere_with_materials(
dt_seconds,
@@ -3602,22 +4267,6 @@ impl DynamicEnvironment {
&self.materials,
fx_camera,
);
- let mut uniforms = renderer.frame_uniforms();
- uniforms.point_light = environment_frame
- .point_lights
- .iter()
- .find(|light| light.active)
- .map(|light| VulkanPointLight {
- position: light.position,
- rgb: [light.color[0], light.color[1], light.color[2]],
- range: light.range,
- coefficients: light.attenuation,
- active: true,
- })
- .unwrap_or_default();
- renderer
- .set_frame_uniforms(uniforms)
- .map_err(|error| format!("update environment point-light uniforms: {error}"))?;
if let Some(audio) = audio.as_mut() {
for sound in &environment_frame.sounds {
audio
@@ -3920,6 +4569,10 @@ fn scale3(value: [f32; 3], factor: f32) -> [f32; 3] {
[value[0] * factor, value[1] * factor, value[2] * factor]
}
+fn multiply3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] * right[0], left[1] * right[1], left[2] * right[2]]
+}
+
fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 {
left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2]))
}
@@ -3960,20 +4613,22 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
struct StaticPreviewScene {
mesh: VulkanStaticMesh,
native_handedness_by_range: HashMap<usize, i8>,
+ type16_present_ranges: HashSet<usize>,
materials: Vec<VulkanStaticMaterial>,
material_animations: Vec<MaterialAnimationBinding>,
+ ambient_effect_slots: Vec<AmbientEffectGpuSlot>,
+ control_point_light_slots: Vec<ControlPointLightSlot>,
+ component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
+ component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
+ dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>,
+ building_collision_bindings: Vec<BuildingCollisionBinding>,
+ control_animation_instances: Vec<Vec<Option<ControlAnimationInstance>>>,
+ dynamic_shadow_casters: Vec<DynamicShadowCaster>,
sun_occlusion_range_indices: Vec<usize>,
shadow_casters: Vec<ShadowCaster>,
- shadow_sphere_baselines: Vec<ShadowSphere>,
shadow_receivers: Vec<ShadowTriangle>,
shadow_material_index: usize,
terrain: Arc<TerrainWorld>,
- component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
- component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
- control_animation_instances: Vec<ControlAnimationInstance>,
- dynamic_component_geometry: Vec<DynamicComponentGeometry>,
- dynamic_component_mounts: Vec<DynamicComponentMount>,
- shadow_caster_indices_by_object: Vec<Option<usize>>,
camera: VulkanStaticCamera,
view_camera: PreviewCameraFrame,
captured_viewport: Option<CapturedViewport>,
@@ -3983,33 +4638,245 @@ struct StaticPreviewScene {
terrain_components: usize,
}
-#[derive(Clone, Debug)]
+#[derive(Clone)]
struct ControlAnimationInstance {
- object_index: usize,
- component_index: usize,
- model: Arc<ModelAsset>,
control: PreparedComponentControl,
+ model: ModelAsset,
+ gate_values: Vec<Option<f32>>,
state: ControlInstanceState,
+ last_sampled_node_times: Vec<Option<f32>>,
+ reported_unsupported_fallback: bool,
}
-#[derive(Clone, Debug)]
+#[derive(Clone)]
struct DynamicComponentGeometry {
+ model: ModelAsset,
+ root_transform: LegacyIron3dEulerTransform,
+ owner_scale: [f32; 3],
+ parent_mount: Option<(usize, usize)>,
+ vertex_start: usize,
+ vertex_count: usize,
+}
+
+#[derive(Clone)]
+struct BuildingCollisionBinding {
object_index: usize,
component_index: usize,
+ model_source: String,
model: Arc<ModelAsset>,
- transform: LegacyIron3dEulerTransform,
- scale: [f32; 3],
- vertex_start: usize,
- vertex_count: usize,
- shadow_triangle_range: Option<std::ops::Range<usize>>,
+ root_transform: LegacyIron3dEulerTransform,
+ owner_scale: [f32; 3],
+ mounted: bool,
+ selected_nodes: Vec<(NodeId, SlotId)>,
+}
+
+fn building_collision_binding(
+ object_index: usize,
+ component_index: usize,
+ model_source: String,
+ model: Arc<ModelAsset>,
+ root_transform: LegacyIron3dEulerTransform,
+ owner_scale: [f32; 3],
+ mounted: bool,
+) -> Result<BuildingCollisionBinding, String> {
+ let context = || {
+ format!(
+ "building collision object {object_index} component {component_index} model {model_source}"
+ )
+ };
+ if model.node_stride != 38 || model.node_count == 0 || model.animation.is_none() {
+ return Err(format!("{} has no complete Node38 pose data", context()));
+ }
+ if model.triangle_descriptors.is_none() {
+ return Err(format!("{} has no type-7 collision descriptors", context()));
+ }
+ if root_transform.try_row_major().is_none()
+ || !owner_scale.iter().all(|value| value.is_finite())
+ || owner_scale.contains(&0.0)
+ {
+ return Err(format!("{} has an invalid world transform", context()));
+ }
+ let first_node = usize::from(mounted);
+ let mut selected_nodes = Vec::new();
+ for node_index in first_node..model.node_count {
+ let node = NodeId(
+ u32::try_from(node_index)
+ .map_err(|_| format!("{} node index exceeds u32", context()))?,
+ );
+ let Some(slot) = selected_slot(&model, node, Lod(0), Group(0)) else {
+ continue;
+ };
+ let slot_index = usize::try_from(slot.0)
+ .map_err(|_| format!("{} node {} has an invalid slot id", context(), node.0))?;
+ let slot_data = model.slots.get(slot_index).ok_or_else(|| {
+ format!(
+ "{} node {} selects missing slot {}",
+ context(),
+ node.0,
+ slot.0
+ )
+ })?;
+ let batch_start = usize::from(slot_data.batch_start);
+ let batch_end = batch_start
+ .checked_add(usize::from(slot_data.batch_count))
+ .ok_or_else(|| format!("{} node {} batch range overflows", context(), node.0))?;
+ let batches = model.batches.get(batch_start..batch_end).ok_or_else(|| {
+ format!(
+ "{} node {} batch range {batch_start}..{batch_end} is invalid",
+ context(),
+ node.0
+ )
+ })?;
+ if batches.iter().any(|batch| batch.index_count != 0) {
+ selected_nodes.push((node, slot));
+ }
+ }
+ Ok(BuildingCollisionBinding {
+ object_index,
+ component_index,
+ model_source,
+ model,
+ root_transform,
+ owner_scale,
+ mounted,
+ selected_nodes,
+ })
+}
+
+fn building_collision_nodes(
+ binding: &BuildingCollisionBinding,
+ pose_buffer: Option<&NodePoseBuffer>,
+ mount_pose: Option<VulkanNodePose>,
+) -> Result<Vec<BuildingMeshNode>, String> {
+ let context = || {
+ format!(
+ "building collision object {} component {} model {}",
+ binding.object_index, binding.component_index, binding.model_source
+ )
+ };
+ if binding.mounted != mount_pose.is_some() {
+ return Err(format!(
+ "{} has {} mount pose, expected {}",
+ context(),
+ if mount_pose.is_some() { "a" } else { "no" },
+ if binding.mounted { "a" } else { "no" }
+ ));
+ }
+ binding
+ .selected_nodes
+ .iter()
+ .map(|(node, slot)| {
+ let node_index =
+ usize::try_from(node.0).map_err(|_| format!("{} node id is invalid", context()))?;
+ let pose = match (pose_buffer, binding.mounted) {
+ (Some(hierarchy), true) => {
+ node38_pose_relative_to_root_from_hierarchy(hierarchy, node_index)
+ }
+ (Some(hierarchy), false) => node38_pose_from_hierarchy(hierarchy, node_index),
+ (None, true) => node38_pose_relative_to_root(&binding.model, None, node_index),
+ (None, false) => node38_pose(&binding.model, None, node_index),
+ }
+ .ok_or_else(|| format!("{} node {} has no current Node38 pose", context(), node.0))?;
+ let world_from_node = component_node_world_matrix(
+ binding.root_transform,
+ binding.owner_scale,
+ mount_pose,
+ pose,
+ )
+ .ok_or_else(|| format!("{} node {} has an invalid world matrix", context(), node.0))?;
+ Ok(BuildingMeshNode {
+ node: *node,
+ slot: *slot,
+ world_from_node,
+ })
+ })
+ .collect()
+}
+
+fn component_node_world_matrix(
+ root_transform: LegacyIron3dEulerTransform,
+ owner_scale: [f32; 3],
+ mount_pose: Option<VulkanNodePose>,
+ node_pose: VulkanNodePose,
+) -> Option<[f32; 16]> {
+ if !owner_scale
+ .iter()
+ .chain(node_pose.translation.iter())
+ .chain(node_pose.rotation.iter())
+ .chain(mount_pose.iter().flat_map(|pose| pose.translation.iter()))
+ .chain(mount_pose.iter().flat_map(|pose| pose.rotation.iter()))
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+ let pose_matrix = |pose: VulkanNodePose| {
+ let [x, y, z] = [
+ rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation),
+ rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation),
+ rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation),
+ ];
+ [
+ x[0],
+ y[0],
+ z[0],
+ pose.translation[0],
+ x[1],
+ y[1],
+ z[1],
+ pose.translation[1],
+ x[2],
+ y[2],
+ z[2],
+ pose.translation[2],
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ]
+ };
+ let scale = [
+ owner_scale[0],
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ owner_scale[1],
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ owner_scale[2],
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ];
+ let mut world = multiply_row_major(root_transform.try_row_major()?, scale);
+ if let Some(mount) = mount_pose {
+ world = multiply_row_major(world, pose_matrix(mount));
+ }
+ world = multiply_row_major(world, pose_matrix(node_pose));
+ world.iter().all(|value| value.is_finite()).then_some(world)
}
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-struct DynamicComponentMount {
+#[derive(Clone, Debug)]
+struct DynamicShadowCaster {
+ caster_index: usize,
object_index: usize,
+ root_transform: LegacyIron3dEulerTransform,
+ owner_scale: [f32; 3],
+ initial_sphere: ShadowSphere,
+ component_draw_range_indices: Vec<usize>,
+ component_spheres: Vec<DynamicShadowSphereSource>,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct DynamicShadowSphereSource {
component_index: usize,
- parent_index: usize,
- socket: usize,
+ center: [f32; 3],
+ radius: f32,
+ mounted: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -4100,6 +4967,1939 @@ fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNod
}
}
+fn control_type4_local_matrix(
+ points: [&PreparedControlPoint; 3],
+ owner_scale: [f32; 3],
+) -> Option<[[f32; 4]; 4]> {
+ if !owner_scale.iter().all(|value| value.is_finite()) {
+ return None;
+ }
+ let positions = points.map(|point| multiply3(point.position, owner_scale));
+ let directions = points.map(|point| multiply3(point.direction, owner_scale));
+ if !positions
+ .iter()
+ .flatten()
+ .chain(directions.iter().flatten())
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+ let reciprocal_three = f32::from_bits(0x3EAA_AAAB);
+ let translation = std::array::from_fn(|axis| {
+ (positions[0][axis] + positions[1][axis] + positions[2][axis]) * reciprocal_three
+ });
+ if !translation.iter().all(|value| value.is_finite()) {
+ return None;
+ }
+
+ let [d0, d1, d2] = directions;
+ if d0 == d1 && d1 == d2 {
+ let magnitude = finite_length3(d0)?;
+ if magnitude <= 0.0 {
+ return None;
+ }
+ let basis = control_3ef0_basis(scale3(d0, magnitude.recip()), translation)?;
+ let scaled = std::array::from_fn(|row| {
+ let mut result = basis[row];
+ for column in 0..3 {
+ result[column] *= magnitude;
+ }
+ result
+ });
+ return scaled
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(scaled);
+ }
+ let (s, a) = if d0 == d2 || d1 == d2 {
+ (d0, d1)
+ } else if d0 == d1 {
+ (d0, d2)
+ } else {
+ let matrix = control_matrix_from_columns(d0, d1, d2, translation);
+ return matrix
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(matrix);
+ };
+
+ // In a repeated-direction row d0 is always the native S vector; the
+ // other vector selected above is A for helper 0x4070.
+ let cross = cross3(a, s);
+ let cross_length = finite_length3(cross)?;
+ if cross_length <= 0.0 {
+ return control_3ef0_basis(s, translation);
+ }
+ let u = scale3(cross, cross_length.recip());
+ let v = cross3(s, u);
+ let matrix = control_matrix_from_columns(s, u, v, translation);
+ matrix
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(matrix)
+}
+
+fn control_3ef0_basis(axis: [f32; 3], translation: [f32; 3]) -> Option<[[f32; 4]; 4]> {
+ if !axis
+ .iter()
+ .chain(translation.iter())
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+ let mut u = if axis[0] == 0.0 {
+ [1.0, 0.0, 0.0]
+ } else if axis[1] == 0.0 {
+ [0.0, 1.0, 0.0]
+ } else {
+ [-axis[1], axis[0], 0.0]
+ };
+ if axis[2] != 0.0 {
+ let u_length = finite_length3(u)?;
+ if u_length <= 0.0 {
+ return None;
+ }
+ u = scale3(u, u_length.recip());
+ }
+ let v = cross3(axis, u);
+ let matrix = control_matrix_from_columns(axis, u, v, translation);
+ matrix
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(matrix)
+}
+
+fn control_matrix_from_columns(
+ first: [f32; 3],
+ second: [f32; 3],
+ third: [f32; 3],
+ translation: [f32; 3],
+) -> [[f32; 4]; 4] {
+ [
+ [first[0], second[0], third[0], translation[0]],
+ [first[1], second[1], third[1], translation[1]],
+ [first[2], second[2], third[2], translation[2]],
+ [0.0, 0.0, 0.0, 1.0],
+ ]
+}
+
+fn finite_length3(value: [f32; 3]) -> Option<f32> {
+ let length = dot3(value, value).sqrt();
+ length.is_finite().then_some(length)
+}
+
+fn control_type4_effect_local_matrix(
+ points: [&PreparedControlPoint; 3],
+ effect_scale: [f32; 3],
+) -> Option<[[f32; 4]; 4]> {
+ if !effect_scale.iter().all(|value| value.is_finite()) {
+ return None;
+ }
+ let mut local = control_type4_local_matrix(points, [1.0; 3])?;
+ for (column, scale) in effect_scale.into_iter().enumerate() {
+ for row in &mut local[..3] {
+ row[column] *= scale;
+ }
+ }
+ local
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(local)
+}
+
+fn control_effect_owner_node_pose(
+ hierarchy: Option<&NodePoseBuffer>,
+ component_is_mounted: bool,
+ owner_node_index: usize,
+) -> Option<VulkanNodePose> {
+ match hierarchy {
+ Some(hierarchy) if component_is_mounted => {
+ node38_pose_relative_to_root_from_hierarchy(hierarchy, owner_node_index)
+ }
+ Some(hierarchy) => node38_pose_from_hierarchy(hierarchy, owner_node_index),
+ None if owner_node_index == 0 => Some(VulkanNodePose {
+ translation: [0.0; 3],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ }),
+ None => None,
+ }
+}
+
+fn node_pose_row_major(pose: VulkanNodePose) -> [[f32; 4]; 4] {
+ let basis = [
+ rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation),
+ rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation),
+ rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation),
+ ];
+ control_matrix_from_columns(basis[0], basis[1], basis[2], pose.translation)
+}
+
+fn component_mount_matrix(mount_pose: VulkanNodePose) -> [[f32; 4]; 4] {
+ node_pose_row_major(mount_pose)
+}
+
+fn component_scale_matrix(scale: [f32; 3]) -> [[f32; 4]; 4] {
+ [
+ [scale[0], 0.0, 0.0, 0.0],
+ [0.0, scale[1], 0.0, 0.0],
+ [0.0, 0.0, scale[2], 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ]
+}
+
+fn matrix_to_row_major(matrix: [[f32; 4]; 4]) -> [f32; 16] {
+ std::array::from_fn(|index| matrix[index / 4][index % 4])
+}
+
+fn control_type4_world_from_effect(
+ local_from_effect: [[f32; 4]; 4],
+ owner_scale: [f32; 3],
+ root_transform: LegacyIron3dEulerTransform,
+ mount_pose: Option<VulkanNodePose>,
+ owner_node_pose: VulkanNodePose,
+) -> Option<[[f32; 4]; 4]> {
+ if !owner_scale
+ .iter()
+ .chain(local_from_effect.iter().flatten())
+ .chain(owner_node_pose.translation.iter())
+ .chain(owner_node_pose.rotation.iter())
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+ let root = root_transform.try_row_major()?;
+ let mut world_flat = multiply_row_major(
+ root,
+ matrix_to_row_major(component_scale_matrix(owner_scale)),
+ );
+ if let Some(mount) = mount_pose {
+ world_flat = multiply_row_major(
+ world_flat,
+ matrix_to_row_major(component_mount_matrix(mount)),
+ );
+ }
+ world_flat = multiply_row_major(
+ world_flat,
+ matrix_to_row_major(node_pose_row_major(owner_node_pose)),
+ );
+ world_flat = multiply_row_major(world_flat, matrix_to_row_major(local_from_effect));
+ let world =
+ std::array::from_fn(|row| std::array::from_fn(|column| world_flat[row * 4 + column]));
+ world
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(world)
+}
+
+fn control_type4_world_from_effect_with_random_shift(
+ mut local_from_effect: [[f32; 4]; 4],
+ random_shift: [f32; 3],
+ owner_scale: [f32; 3],
+ root_transform: LegacyIron3dEulerTransform,
+ mount_pose: Option<VulkanNodePose>,
+ owner_node_pose: VulkanNodePose,
+) -> Option<[[f32; 4]; 4]> {
+ for (axis, value) in random_shift.into_iter().enumerate() {
+ local_from_effect[axis][3] += value;
+ }
+ control_type4_world_from_effect(
+ local_from_effect,
+ owner_scale,
+ root_transform,
+ mount_pose,
+ owner_node_pose,
+ )
+}
+
+#[cfg(test)]
+fn ambient_effect_world_from_component_pose(
+ slot: &AmbientEffectGpuSlot,
+ component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
+) -> Option<[[f32; 4]; 4]> {
+ ambient_effect_world_from_component_pose_with_random_shift(
+ slot,
+ component_pose_buffers,
+ [0.0; 3],
+ )
+}
+
+fn ambient_effect_world_from_component_pose_with_random_shift(
+ slot: &AmbientEffectGpuSlot,
+ component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
+ random_shift: [f32; 3],
+) -> Option<[[f32; 4]; 4]> {
+ let hierarchy = component_pose_buffers
+ .get(slot.owner_object_index)
+ .and_then(|components| components.get(slot.owner_component_index))
+ .and_then(Option::as_ref);
+ let owner_node_pose = control_effect_owner_node_pose(
+ hierarchy,
+ slot.owner_component_is_mounted,
+ slot.owner_node_index,
+ )?;
+ control_type4_world_from_effect_with_random_shift(
+ slot.local_from_effect,
+ random_shift,
+ slot.owner_scale,
+ slot.root_transform,
+ slot.mount_pose,
+ owner_node_pose,
+ )
+}
+
+fn control_point_light_world_from_component_pose_with_random_shift(
+ slot: &ControlPointLightSlot,
+ component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
+ random_shift: [f32; 3],
+) -> Option<[[f32; 4]; 4]> {
+ let hierarchy = component_pose_buffers
+ .get(slot.owner_object_index)
+ .and_then(|components| components.get(slot.owner_component_index))
+ .and_then(Option::as_ref);
+ let owner_node_pose = control_effect_owner_node_pose(
+ hierarchy,
+ slot.owner_component_is_mounted,
+ slot.owner_node_index,
+ )?;
+ control_type4_world_from_effect_with_random_shift(
+ slot.local_from_effect,
+ random_shift,
+ slot.owner_scale,
+ slot.root_transform,
+ slot.mount_pose,
+ owner_node_pose,
+ )
+}
+
+fn update_control_point_lights(
+ slots: &mut [ControlPointLightSlot],
+ component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
+ component_mount_poses: &[Vec<Option<VulkanNodePose>>],
+ elapsed_seconds: f32,
+ random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>,
+ emit_diagnostic: bool,
+) -> Vec<(VulkanPointLight, u32)> {
+ let mut lights = Vec::new();
+ for slot in slots {
+ if slot.owner_component_is_mounted {
+ slot.mount_pose = component_mount_poses
+ .get(slot.owner_object_index)
+ .and_then(|components| components.get(slot.owner_component_index))
+ .copied()
+ .flatten();
+ }
+ let random_sample = random_samples
+ .get(&slot.random_instance_key)
+ .copied()
+ .unwrap_or_default();
+ let Some(world_from_effect) =
+ control_point_light_world_from_component_pose_with_random_shift(
+ slot,
+ component_pose_buffers,
+ random_sample.accumulated_shift,
+ )
+ else {
+ continue;
+ };
+ slot.world_from_effect = world_from_effect;
+ let Some(phase) = ambient_effect_phase(
+ slot.time_mode,
+ slot.header_flags,
+ elapsed_seconds,
+ slot.duration_seconds,
+ )
+ .map(|phase| native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)) else {
+ continue;
+ };
+ let matrix = slot.world_from_effect;
+ let origin = [matrix[0][3], matrix[1][3], matrix[2][3]];
+ let basis = [
+ [matrix[0][0], matrix[1][0], matrix[2][0]],
+ [matrix[0][1], matrix[1][1], matrix[2][1]],
+ [matrix[0][2], matrix[1][2], matrix[2][2]],
+ ];
+ let Some(frame) = evaluate_op1_point_light_with_basis(&slot.command, phase, origin, basis)
+ else {
+ continue;
+ };
+ if !frame.active || frame.light_selector != 1 {
+ continue;
+ }
+ if emit_diagnostic {
+ eprintln!(
+ "control_point_light_detail object={} component={} table={} row={} command={} header_flags=0x{:08X} effect_type={} native_flags=0x{:08X} selector={} command_time={} phase={:.4} local_position_start={:?} local_position_end={:?} world_origin={:?} output_position={:?} rgb={:?} alpha={:.3} range={} attenuation={:?} effect_factor={}",
+ slot.owner_object_index,
+ slot.owner_component_index,
+ slot.random_instance_key.table_index,
+ slot.random_instance_key.row_index,
+ slot.command_index,
+ slot.header_flags,
+ frame.effect_type,
+ frame.native_flags,
+ frame.light_selector,
+ frame.command_time,
+ phase,
+ fx_command_vec3(&slot.command, 12),
+ fx_command_vec3(&slot.command, 24),
+ [matrix[0][3], matrix[1][3], matrix[2][3]],
+ frame.position,
+ frame.color,
+ frame.color[3],
+ frame.range,
+ frame.attenuation,
+ frame.effect_factor,
+ );
+ }
+ lights.push((
+ VulkanPointLight {
+ position: frame.position,
+ rgb: [frame.color[0], frame.color[1], frame.color[2]],
+ range: frame.range,
+ coefficients: frame.attenuation,
+ active: true,
+ },
+ frame.native_flags,
+ ));
+ }
+ lights
+}
+
+fn fx_command_vec3(command: &FxCommand, offset: usize) -> Option<[f32; 3]> {
+ let mut result = [0.0; 3];
+ for (axis, value) in result.iter_mut().enumerate() {
+ let start = offset.checked_add(axis.checked_mul(4)?)?;
+ let bytes = command.raw_body.get(start..start.checked_add(4)?)?;
+ *value = f32::from_le_bytes(bytes.try_into().ok()?);
+ }
+ Some(result)
+}
+
+fn report_control_point_lights_once(
+ reported: &mut bool,
+ control_clock_ms: u32,
+ target_clock_ms: Option<u32>,
+ environment_count: usize,
+ raw_control_lights: &[(VulkanPointLight, u32)],
+ ordinary_control_lights: &[VulkanPointLight],
+) {
+ if *reported || std::env::var_os("FPARKAN_CONTROL_POINT_LIGHT_DIAGNOSTIC").is_none() {
+ return;
+ }
+ if target_clock_ms.is_some_and(|target| control_clock_ms < target) {
+ return;
+ }
+ eprintln!(
+ "control_point_lights clock_ms={control_clock_ms} environment_count={environment_count} control_raw_count={} control_ordinary_count={} raw_flags={:?}",
+ raw_control_lights.len(),
+ ordinary_control_lights.len(),
+ raw_control_lights.iter().map(|(_, flags)| *flags).collect::<Vec<_>>(),
+ );
+ *reported = true;
+}
+
+fn ordinary_control_point_lights(
+ raw_control_lights: &[(VulkanPointLight, u32)],
+) -> Vec<VulkanPointLight> {
+ raw_control_lights
+ .iter()
+ .filter(|(light, flags)| light.active && flags & 0x8000_0000 == 0)
+ .map(|(light, _)| *light)
+ .collect()
+}
+
+fn initial_native_effect_phase(header_flags: u32, phase: f32) -> f32 {
+ if header_flags & 0x80 != 0 {
+ 0.0
+ } else {
+ phase
+ }
+}
+
+fn native_command_phase(header_flags: u32, phase: f32, random_phase_jitter: f32) -> f32 {
+ let initial_phase = initial_native_effect_phase(header_flags, phase);
+ if initial_phase == -1.0 {
+ -1.0
+ } else {
+ (initial_phase
+ + if header_flags & 1 != 0 {
+ random_phase_jitter
+ } else {
+ 0.0
+ })
+ .clamp(0.0, 1.0)
+ }
+}
+
+fn control_row_points<'a>(
+ point_list: &'a PreparedControlPointList,
+ row: &PreparedControlEffectRow,
+) -> Option<[&'a PreparedControlPoint; 3]> {
+ let mut result = Vec::with_capacity(3);
+ for offset in row.point_offsets {
+ result.push(point_list.points.get(usize::try_from(offset).ok()?)?);
+ }
+ result.try_into().ok()
+}
+
+fn append_initial_control_effect_slots(
+ root: &Path,
+ mission_root: &MissionObjectDraft,
+ object_index: usize,
+ component_index: usize,
+ control: &PreparedComponentControl,
+ component_pose_buffer: Option<&NodePoseBuffer>,
+ mount_pose: Option<VulkanNodePose>,
+ mesh: &mut VulkanStaticMesh,
+ materials: &mut Vec<VulkanStaticMaterial>,
+ material_animations: &mut Vec<MaterialAnimationBinding>,
+ effect_documents: &mut HashMap<String, FxDocument>,
+ effect_materials: &mut HashMap<String, PreparedEffectMaterial>,
+ slots: &mut Vec<AmbientEffectGpuSlot>,
+ point_light_slots: &mut Vec<ControlPointLightSlot>,
+) -> Result<(), String> {
+ let Some(point_list) = control.point_list.as_ref() else {
+ return Ok(());
+ };
+ for row in
+ selected_initial_control_effect_rows(control.initial_effect_group, &control.effect_section)
+ {
+ let group_index = row.table_index;
+ let archive = fixed_effect_field(&row.resource.archive);
+ let name = fixed_effect_field(&row.resource.name);
+ let points = control_row_points(point_list, row).ok_or_else(|| {
+ format!(
+ "Control group {group_index} row {} references a missing CPT point {:?}",
+ row.row_index, row.point_offsets
+ )
+ })?;
+ let key = format!("{archive}/{name}");
+ if !effect_documents.contains_key(&key) {
+ let bytes = load_resource_entry_bytes_from_root(root, &archive, &name)
+ .map_err(|error| format!("load Control FX {key}: {error}"))?;
+ let Ok(document) = decode_fxid(bytes) else {
+ continue;
+ };
+ effect_documents.insert(key.clone(), document);
+ }
+ let document = effect_documents
+ .get(&key)
+ .ok_or_else(|| format!("Control FX {key} was not cached"))?;
+ let header = document.header();
+ let duration_seconds = header.duration_seconds;
+ if !duration_seconds.is_finite()
+ || duration_seconds < 0.0
+ || !matches!(header.time_mode, 0 | 1 | 2)
+ || (header.time_mode != 0 && duration_seconds <= 0.0)
+ || !header.phase_jitter.is_finite()
+ || !header.random_shift.iter().all(|value| value.is_finite())
+ || !header.scale.iter().all(|value| value.is_finite())
+ {
+ // Control can reference action effects whose header timing and
+ // scale conventions are outside this ambient subset. Leave them
+ // unexpanded until their lifetime and transform contract are
+ // understood rather than failing the whole mission preview.
+ continue;
+ }
+ let owner_node_index =
+ usize::try_from(points[0].raw_signed_field_04)
+ .ok()
+ .filter(|owner_node| {
+ component_pose_buffer
+ .is_some_and(|hierarchy| *owner_node < hierarchy.poses.len())
+ || (*owner_node == 0 && component_pose_buffer.is_none())
+ });
+ let Some(owner_node_index) = owner_node_index else {
+ continue;
+ };
+ let owner_node_pose = control_effect_owner_node_pose(
+ component_pose_buffer,
+ mount_pose.is_some(),
+ owner_node_index,
+ );
+ let Some(owner_node_pose) = owner_node_pose else {
+ continue;
+ };
+ let root_transform = LegacyIron3dEulerTransform {
+ translation: mission_root.position,
+ orientation_radians: mission_root.orientation_raw,
+ };
+ let local_from_effect = control_type4_effect_local_matrix(points, header.scale)
+ .ok_or_else(|| format!("Control FX {key} has an invalid type-4 transform"))?;
+ let world_from_effect = control_type4_world_from_effect(
+ local_from_effect,
+ mission_root.scale,
+ root_transform,
+ mount_pose,
+ owner_node_pose,
+ )
+ .ok_or_else(|| format!("Control FX {key} has an invalid owner transform"))?;
+ let control_source = format!(
+ "{}/{}",
+ control.source.archive.as_str(),
+ String::from_utf8_lossy(&control.source.name.0)
+ );
+ let random_instance_key = ControlEffectInstanceKey {
+ object_index,
+ component_index,
+ table_index: row.table_index,
+ row_index: row.row_index,
+ };
+ let random_seed = control_effect_instance_seed(
+ object_index,
+ component_index,
+ row.table_index,
+ row.row_index,
+ &control_source,
+ );
+
+ for (command_index, command) in document.commands().iter().enumerate() {
+ if ambient_effect_op1_supported(command) {
+ point_light_slots.push(ControlPointLightSlot {
+ time_mode: header.time_mode,
+ header_flags: header.flags,
+ random_instance_key,
+ random_seed,
+ phase_jitter: header.phase_jitter,
+ random_shift: header.random_shift,
+ duration_seconds,
+ owner_object_index: object_index,
+ owner_component_index: component_index,
+ owner_node_index,
+ owner_component_is_mounted: mount_pose.is_some(),
+ root_transform,
+ owner_scale: mission_root.scale,
+ mount_pose,
+ local_from_effect,
+ world_from_effect,
+ command_index,
+ command: command.clone(),
+ });
+ continue;
+ }
+ let Some(kind) = ambient_effect_command_kind(command) else {
+ continue;
+ };
+ if !ambient_effect_render_selector_allows_header(
+ header.flags,
+ CONTROL_COMPONENT_RENDER_SELECTOR,
+ ) {
+ continue;
+ }
+ let Some(reference) = command.resource_refs.first() else {
+ continue;
+ };
+ let material_archive = String::from_utf8_lossy(reference.archive_name()).into_owned();
+ let material_name = String::from_utf8_lossy(reference.resource_name()).into_owned();
+ if material_archive.is_empty() || material_name.is_empty() {
+ continue;
+ }
+ let cache_key = format!("{material_archive}/{material_name}");
+ if !effect_materials.contains_key(&cache_key) {
+ let _prepared = prepare_effect_material(
+ root,
+ &material_archive,
+ &material_name,
+ materials,
+ effect_materials,
+ )?;
+ }
+ let prepared = effect_materials
+ .get(&cache_key)
+ .ok_or_else(|| format!("FX MAT0 {cache_key} was not cached"))?;
+ if !ambient_effect_tint_supported(
+ &prepared.phase_native_flag_40,
+ &prepared.phase_directional_rgb,
+ &prepared.phase_additive_rgb,
+ ) {
+ continue;
+ }
+ let first_material = *prepared
+ .phase_material_indices
+ .first()
+ .ok_or_else(|| format!("FX MAT0 {cache_key} has no phase variants"))?;
+ let op9_mesh_template =
+ if kind == AmbientEffectKind::GenerBall && command.opcode == FxOpcode::Op9 {
+ let Some(template) = evaluate_f_gener_ball_mesh(
+ command,
+ command_index,
+ &world_from_effect,
+ 0.0,
+ 0.0,
+ [0.0; 3],
+ AMBIENT_EFFECT_NATIVE_CATEGORY,
+ ) else {
+ continue;
+ };
+ Some(template)
+ } else {
+ None
+ };
+ let op9_primitive_layout = op9_mesh_template
+ .as_ref()
+ .map(ambient_effect_mesh_primitive_layout)
+ .transpose()?;
+ let placeholder: [VulkanStaticVertex; 4] = std::array::from_fn(|index| {
+ environment_vertex_with_alpha(
+ [0.0; 3],
+ [1.0; 3],
+ 0.0,
+ [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]][index],
+ )
+ });
+ let pipeline_state = ambient_effect_pipeline_state(prepared.initial_pipeline_category)
+ .ok_or_else(|| {
+ format!(
+ "FX MAT0 {cache_key} has unsupported pipeline category {}",
+ prepared.initial_pipeline_category
+ )
+ })?;
+ let pool_capacity = match kind {
+ AmbientEffectKind::SmokePool => ambient_effect_pool_capacity(command)
+ .ok_or_else(|| format!("FX Op8 {cache_key} has an invalid pool size"))?,
+ AmbientEffectKind::Op7ParticlePool => ambient_effect_op7_range_capacity(command)
+ .ok_or_else(|| format!("FX Op7 {cache_key} has an invalid particle pool"))?,
+ _ => 1,
+ };
+ let range_capacity = op9_primitive_layout
+ .as_ref()
+ .map_or(pool_capacity, Vec::len);
+ let mut particle_ranges = Vec::with_capacity(range_capacity);
+ let mut particle_material_animation_indices = Vec::with_capacity(range_capacity);
+ for _ in 0..pool_capacity {
+ if let (Some(template), Some(primitives)) =
+ (&op9_mesh_template, &op9_primitive_layout)
+ {
+ for primitive in primitives {
+ let vertices = ambient_effect_mesh_primitive_vertices(template, *primitive);
+ let indices = ambient_effect_mesh_primitive_indices(template, *primitive)?;
+ let range = append_environment_range(
+ mesh,
+ first_material,
+ vertices,
+ indices,
+ pipeline_state,
+ )?;
+ let material_animation_index = append_effect_material_animation(
+ prepared,
+ range.range_index,
+ material_animations,
+ );
+ particle_ranges.push(range);
+ particle_material_animation_indices.push(material_animation_index);
+ }
+ } else {
+ let range = append_environment_range(
+ mesh,
+ first_material,
+ placeholder.to_vec(),
+ vec![0, 1, 2, 2, 3, 0],
+ pipeline_state,
+ )?;
+ let material_animation_index = append_effect_material_animation(
+ prepared,
+ range.range_index,
+ material_animations,
+ );
+ particle_ranges.push(range);
+ particle_material_animation_indices.push(material_animation_index);
+ }
+ }
+ let range = particle_ranges.remove(0);
+ let material_animation_index = particle_material_animation_indices.remove(0);
+ let smoke_emitter_state = (kind == AmbientEffectKind::SmokePool).then(|| {
+ let seed = ambient_effect_seed(
+ object_index,
+ component_index,
+ row.table_index,
+ row.row_index,
+ command_index,
+ &control_source,
+ );
+ SmokeEmitterState::new(seed, seed, seed)
+ });
+ let op7_emitter_state = (kind == AmbientEffectKind::Op7ParticlePool).then(|| {
+ let seed = ambient_effect_seed(
+ object_index,
+ component_index,
+ row.table_index,
+ row.row_index,
+ command_index,
+ &control_source,
+ );
+ Op7EmitterState::new(seed, seed)
+ });
+ slots.push(AmbientEffectGpuSlot {
+ kind,
+ time_mode: header.time_mode,
+ header_flags: header.flags,
+ random_instance_key,
+ random_seed,
+ phase_jitter: header.phase_jitter,
+ random_shift: header.random_shift,
+ owner_object_index: object_index,
+ owner_component_index: component_index,
+ owner_node_index,
+ owner_component_is_mounted: mount_pose.is_some(),
+ root_transform,
+ owner_scale: mission_root.scale,
+ mount_pose,
+ local_from_effect,
+ control_source: control_source.clone(),
+ control_table_index: row.table_index,
+ control_row_index: row.row_index,
+ control_point_indices: row.point_offsets,
+ control_point_owners: points.map(|point| point.raw_signed_field_04),
+ command: command.clone(),
+ command_index,
+ duration_seconds,
+ world_from_effect,
+ range,
+ additional_particle_ranges: particle_ranges,
+ material_animation_index,
+ additional_particle_material_animation_indices: particle_material_animation_indices,
+ smoke_emitter_state,
+ op7_emitter_state,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn selected_initial_control_effect_rows<'a>(
+ group_index: Option<usize>,
+ section: &'a PreparedControlEffectSection,
+) -> Vec<&'a PreparedControlEffectRow> {
+ let (Some(group_index), PreparedControlEffectSection::Parsed(rows)) = (group_index, section)
+ else {
+ return Vec::new();
+ };
+ rows.iter()
+ .filter(|row| {
+ row.table_index == group_index && ambient_effect_row_unconditionally_active(row)
+ })
+ .collect()
+}
+
+fn ambient_effect_row_unconditionally_active(row: &PreparedControlEffectRow) -> bool {
+ // Control22C0 returns true when none of the predicate bits are set. The
+ // two row-chain bits have separate dispatch semantics and stay excluded.
+ row.raw_condition[0] & 0xF000_0000 == 0
+}
+
+fn ambient_effect_command_kind(command: &FxCommand) -> Option<AmbientEffectKind> {
+ let body = command.raw_body.as_ref();
+ let mode = body
+ .get(..4)
+ .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four byte FX body mode")));
+ let body_c4 = body
+ .get(0xc4..0xc8)
+ .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four byte FX C4 field")));
+ match (command.opcode, mode, body_c4) {
+ (FxOpcode::Op3, Some(0), _) if command.raw_body.len() >= 0xC4 => {
+ Some(AmbientEffectKind::Op3Type0)
+ }
+ (FxOpcode::Op3, Some(1), _) if command.raw_body.len() >= 0xC4 => {
+ Some(AmbientEffectKind::Op3Type1)
+ }
+ (FxOpcode::Op3, Some(2), _) => Some(AmbientEffectKind::Op3Type2),
+ (FxOpcode::Op9, Some(2), Some(0..=2)) => Some(AmbientEffectKind::GenerBall),
+ (FxOpcode::Op7, _, _) if ambient_effect_op7_range_capacity(command).is_some() => {
+ Some(AmbientEffectKind::Op7ParticlePool)
+ }
+ (FxOpcode::Op8, _, _) if ambient_effect_pool_capacity(command).is_some() => {
+ Some(AmbientEffectKind::SmokePool)
+ }
+ (FxOpcode::Op4, Some(0), Some(denominator_bits))
+ if f32::from_bits(denominator_bits).is_finite()
+ && f32::from_bits(denominator_bits) > 0.0 =>
+ {
+ Some(AmbientEffectKind::GenerBall)
+ }
+ _ => None,
+ }
+}
+
+const CONTROL_COMPONENT_RENDER_SELECTOR: u32 = 1;
+
+fn ambient_effect_render_selector_allows_header(header_flags: u32, render_selector: u32) -> bool {
+ // Effect.dll 0x7D10 suppresses header flag 0x800 only for selector zero.
+ // The native AniMesh component path was observed calling the manager with
+ // selector one while its 0xD00 f_smalllight_y entries were present.
+ render_selector != 0 || header_flags & 0x800 == 0
+}
+
+fn ambient_effect_op1_supported(command: &FxCommand) -> bool {
+ if command.opcode != FxOpcode::Op1 {
+ return false;
+ }
+ let Some(effect_type) = command
+ .raw_body
+ .get(..4)
+ .and_then(|bytes| bytes.try_into().ok())
+ .map(u32::from_le_bytes)
+ else {
+ return false;
+ };
+ command.raw_body.len() >= 132 && matches!(effect_type, 1 | 2 | 5 | 6 | 7)
+}
+
+fn ambient_effect_pool_capacity(command: &FxCommand) -> Option<usize> {
+ if command.opcode != FxOpcode::Op8 {
+ return None;
+ }
+ let pool_count = usize::try_from(u32::from_le_bytes(
+ command.raw_body.get(0x20..0x24)?.try_into().ok()?,
+ ))
+ .ok()?;
+ (1..=4096).contains(&pool_count).then_some(pool_count)
+}
+
+fn ambient_effect_op7_range_capacity(command: &FxCommand) -> Option<usize> {
+ if command.opcode != FxOpcode::Op7 || command.raw_body.len() < 0xCC {
+ return None;
+ }
+ let read_u32 = |offset: usize| -> Option<u32> {
+ Some(u32::from_le_bytes(
+ command.raw_body.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+ };
+ let read_f32 = |offset| read_u32(offset).map(f32::from_bits);
+ // The evaluator uses the traced default world scalar of 10 for this
+ // Control path; the command's acceleration is supported when finite.
+ let material_phase_factor = read_f32(0x1C)?;
+ if !matches!(read_u32(0x00)?, 0 | 1)
+ || !material_phase_factor.is_finite()
+ || material_phase_factor == 0.0
+ || !read_f32(0x88)?.is_finite()
+ {
+ return None;
+ }
+ let pool_count = usize::try_from(read_u32(0x20)?).ok()?;
+ let copies_per_group = usize::try_from(read_u32(0x24)?).ok()?;
+ let capacity = pool_count.checked_mul(copies_per_group)?;
+ (1..=4096).contains(&capacity).then_some(capacity)
+}
+
+fn ambient_effect_seed(
+ object_index: usize,
+ component_index: usize,
+ table_index: usize,
+ row_index: usize,
+ command_index: usize,
+ source: &str,
+) -> u32 {
+ let mut hash = 0x811C_9DC5_u32;
+ for byte in source.bytes().chain(
+ [
+ object_index,
+ component_index,
+ table_index,
+ row_index,
+ command_index,
+ ]
+ .into_iter()
+ .flat_map(|value| (value as u64).to_le_bytes()),
+ ) {
+ hash = (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193);
+ }
+ if hash == 0 {
+ 1
+ } else {
+ hash
+ }
+}
+
+fn control_effect_instance_seed(
+ object_index: usize,
+ component_index: usize,
+ table_index: usize,
+ row_index: usize,
+ source: &str,
+) -> u32 {
+ ambient_effect_seed(
+ object_index,
+ component_index,
+ table_index,
+ row_index,
+ 0,
+ source,
+ )
+}
+
+fn advance_control_effect_random_states(
+ states: &mut HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
+ ambient_slots: &[AmbientEffectGpuSlot],
+ point_light_slots: &[ControlPointLightSlot],
+ sample_id: SceneSampleId,
+ control_clock_ms: u32,
+ elapsed_seconds: f32,
+) -> HashMap<ControlEffectInstanceKey, ControlEffectRandomSample> {
+ let mut specifications = HashMap::new();
+ for slot in ambient_slots {
+ let initial_phase = ambient_effect_phase(
+ slot.time_mode,
+ slot.header_flags,
+ elapsed_seconds,
+ slot.duration_seconds,
+ )
+ .map(|phase| initial_native_effect_phase(slot.header_flags, phase))
+ .unwrap_or(-1.0);
+ specifications.entry(slot.random_instance_key).or_insert((
+ slot.random_seed,
+ slot.header_flags,
+ slot.phase_jitter,
+ slot.random_shift,
+ initial_phase,
+ ));
+ }
+ for slot in point_light_slots {
+ let initial_phase = ambient_effect_phase(
+ slot.time_mode,
+ slot.header_flags,
+ elapsed_seconds,
+ slot.duration_seconds,
+ )
+ .map(|phase| initial_native_effect_phase(slot.header_flags, phase))
+ .unwrap_or(-1.0);
+ specifications.entry(slot.random_instance_key).or_insert((
+ slot.random_seed,
+ slot.header_flags,
+ slot.phase_jitter,
+ slot.random_shift,
+ initial_phase,
+ ));
+ }
+
+ specifications
+ .into_iter()
+ .map(
+ |(key, (seed, flags, phase_jitter, random_shift, initial_phase))| {
+ let state = states.entry(key).or_insert_with(|| {
+ ControlEffectRandomState::new(seed, flags, phase_jitter, random_shift)
+ });
+ (
+ key,
+ state.advance(sample_id, control_clock_ms, initial_phase),
+ )
+ },
+ )
+ .collect()
+}
+
+fn ambient_effect_ranges(slot: &AmbientEffectGpuSlot) -> Vec<(&EnvironmentGpuRange, usize)> {
+ std::iter::once((&slot.range, slot.material_animation_index))
+ .chain(
+ slot.additional_particle_ranges
+ .iter()
+ .zip(&slot.additional_particle_material_animation_indices)
+ .map(|(range, &binding)| (range, binding)),
+ )
+ .collect()
+}
+
+fn ambient_effect_quad_center(vertices: &[[f32; 3]; 4]) -> [f32; 3] {
+ std::array::from_fn(|axis| vertices.iter().map(|vertex| vertex[axis]).sum::<f32>() / 4.0)
+}
+
+fn ambient_effect_quad_size(vertices: &[[f32; 3]; 4]) -> [f32; 2] {
+ let edge_length = |a: [f32; 3], b: [f32; 3]| {
+ ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
+ };
+ [
+ edge_length(vertices[0], vertices[1]),
+ edge_length(vertices[1], vertices[2]),
+ ]
+}
+
+fn ambient_effect_projected_bounds(
+ vertices: &[[f32; 3]; 4],
+ clip_from_world: [f32; 16],
+ viewport: [u32; 2],
+) -> Option<[f32; 4]> {
+ let mut points = [[0.0; 2]; 4];
+ for (index, vertex) in vertices.iter().enumerate() {
+ let clip = [
+ vertex[0] * clip_from_world[0]
+ + vertex[1] * clip_from_world[4]
+ + vertex[2] * clip_from_world[8]
+ + clip_from_world[12],
+ vertex[0] * clip_from_world[1]
+ + vertex[1] * clip_from_world[5]
+ + vertex[2] * clip_from_world[9]
+ + clip_from_world[13],
+ vertex[0] * clip_from_world[2]
+ + vertex[1] * clip_from_world[6]
+ + vertex[2] * clip_from_world[10]
+ + clip_from_world[14],
+ vertex[0] * clip_from_world[3]
+ + vertex[1] * clip_from_world[7]
+ + vertex[2] * clip_from_world[11]
+ + clip_from_world[15],
+ ];
+ if !clip.iter().all(|value| value.is_finite()) || clip[3] <= f32::EPSILON {
+ return None;
+ }
+ points[index] = [
+ (clip[0] / clip[3] + 1.0) * 0.5 * viewport[0] as f32,
+ (clip[1] / clip[3] + 1.0) * 0.5 * viewport[1] as f32,
+ ];
+ }
+ Some([
+ points
+ .iter()
+ .map(|point| point[0])
+ .fold(f32::INFINITY, f32::min),
+ points
+ .iter()
+ .map(|point| point[1])
+ .fold(f32::INFINITY, f32::min),
+ points
+ .iter()
+ .map(|point| point[0])
+ .fold(f32::NEG_INFINITY, f32::max),
+ points
+ .iter()
+ .map(|point| point[1])
+ .fold(f32::NEG_INFINITY, f32::max),
+ ])
+}
+
+fn ambient_effect_range_for(
+ slot: &AmbientEffectGpuSlot,
+ range_index: usize,
+) -> Option<&EnvironmentGpuRange> {
+ std::iter::once(&slot.range)
+ .chain(slot.additional_particle_ranges.iter())
+ .find(|range| range.range_index == range_index)
+}
+
+fn ambient_effect_pipeline_state(category: u8) -> Option<LegacyPipelineState> {
+ world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly)
+ .with_material_category(category)
+}
+
+fn ambient_effect_tint_supported(
+ phase_native_flag_40: &[bool],
+ phase_directional_rgb: &[[f32; 3]],
+ phase_additive_rgb: &[[f32; 3]],
+) -> bool {
+ !phase_native_flag_40.is_empty()
+ && phase_native_flag_40.len() == phase_directional_rgb.len()
+ && phase_native_flag_40.len() == phase_additive_rgb.len()
+ && phase_native_flag_40.iter().all(|flag| !flag)
+ && phase_directional_rgb
+ .iter()
+ .zip(phase_additive_rgb)
+ .all(|(directional, additive)| {
+ directional
+ .iter()
+ .chain(additive)
+ .all(|channel| channel.is_finite() && (0.0..=1.0).contains(channel))
+ && (0..3).all(|channel| additive[channel] > 0.0 || directional[channel] == 0.0)
+ })
+}
+
+fn fixed_effect_field(field: &[u8; 32]) -> String {
+ let length = field
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(field.len());
+ String::from_utf8_lossy(&field[..length]).trim().to_owned()
+}
+
+fn update_ambient_effect_geometry(
+ slots: &mut [AmbientEffectGpuSlot],
+ component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
+ mesh: &mut VulkanStaticMesh,
+ material_animations: &mut [MaterialAnimationBinding],
+ elapsed_seconds: f32,
+ random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>,
+ camera_world: [f32; 3],
+) -> Result<Vec<AmbientEffectGeometryUpdate>, String> {
+ let mut updates = Vec::with_capacity(slots.len());
+ for slot in slots {
+ let random_sample = random_samples
+ .get(&slot.random_instance_key)
+ .copied()
+ .unwrap_or_default();
+ let Some(world_from_effect) = ambient_effect_world_from_component_pose_with_random_shift(
+ slot,
+ component_pose_buffers,
+ random_sample.accumulated_shift,
+ ) else {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ };
+ slot.world_from_effect = world_from_effect;
+ let Some(phase) = ambient_effect_phase(
+ slot.time_mode,
+ slot.header_flags,
+ elapsed_seconds,
+ slot.duration_seconds,
+ )
+ .map(|phase| native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)) else {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ };
+ if slot.kind == AmbientEffectKind::GenerBall && slot.command.opcode == FxOpcode::Op9 {
+ let Some(frame) = evaluate_f_gener_ball_mesh(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ phase,
+ elapsed_seconds,
+ camera_world,
+ AMBIENT_EFFECT_NATIVE_CATEGORY,
+ ) else {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ };
+ let primitives = ambient_effect_mesh_primitive_layout(&frame)?;
+ let ranges = ambient_effect_ranges(slot);
+ if ranges.len() != primitives.len() {
+ return Err(format!(
+ "ambient Op9 draw range count changed ({} != {})",
+ ranges.len(),
+ primitives.len()
+ ));
+ }
+ for (primitive, (range, material_animation_index)) in primitives.into_iter().zip(ranges)
+ {
+ let binding = material_animations
+ .get_mut(material_animation_index)
+ .ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?;
+ binding.normalized_phase_override = Some(frame.material_phase);
+ write_ambient_effect_mesh_primitive(mesh, range, &frame, primitive)?;
+ updates.push(AmbientEffectGeometryUpdate {
+ range_index: range.range_index,
+ index_count: if frame.active && frame.color[3] > 0.0 {
+ u32::try_from(primitive.index_count)
+ .map_err(|_| "ambient Op9 index count exceeds u32".to_string())?
+ } else {
+ 0
+ },
+ command_rgb: [frame.color[0], frame.color[1], frame.color[2]],
+ });
+ }
+ continue;
+ }
+ let frames = match (slot.kind, slot.command.opcode) {
+ (AmbientEffectKind::Op3Type0, FxOpcode::Op3) => evaluate_op3_type0_command(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ elapsed_seconds,
+ phase,
+ camera_world,
+ )
+ .into_iter()
+ .collect(),
+ (AmbientEffectKind::Op7ParticlePool, FxOpcode::Op7) => {
+ let Some(state) = slot.op7_emitter_state.as_mut() else {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ };
+ let Some(frames) = evaluate_op7_emitter(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ camera_world,
+ phase,
+ elapsed_seconds,
+ Op7RenderContext {
+ detail_level: 0,
+ // At detail level zero, [0,1) cutoffs with zero bias
+ // are all visible. The native quality preset remains
+ // unresolved, but no particle is culled here.
+ visibility: Op7VisibilityProfile {
+ width: 1.0,
+ bias: 0.0,
+ },
+ // Terrain.dll initializes the native world's world
+ // scalar to 10.0. An engine integration can supply the
+ // live value after it exposes the world setting.
+ world_scalar: 10.0,
+ },
+ state,
+ ) else {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ };
+ fx_material_speed_diagnostic(slot, 0xAC, elapsed_seconds, phase, &frames);
+ frames
+ }
+ (AmbientEffectKind::SmokePool, FxOpcode::Op8) => {
+ let Some(state) = slot.smoke_emitter_state.as_mut() else {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ };
+ let frames = evaluate_op8_emitter(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ camera_world,
+ elapsed_seconds,
+ phase,
+ 0,
+ Op8DetailThresholdRange::new(1.0, 1.0)
+ .expect("ordered finite Op8 material threshold"),
+ state,
+ )
+ .unwrap_or_default();
+ smoke_fr_02_diagnostic(slot, elapsed_seconds, phase, &frames);
+ fx_material_speed_diagnostic(slot, 0xD4, elapsed_seconds, phase, &frames);
+ frames
+ }
+ (AmbientEffectKind::Op3Type1, FxOpcode::Op3) => evaluate_op3_type1_command(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ elapsed_seconds,
+ phase,
+ camera_world,
+ )
+ .into_iter()
+ .collect(),
+ (AmbientEffectKind::Op3Type2, FxOpcode::Op3) => evaluate_op3_type2_command(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ elapsed_seconds,
+ phase,
+ 0,
+ )
+ .into_iter()
+ .collect(),
+ (AmbientEffectKind::GenerBall, FxOpcode::Op4) => evaluate_f_gener_ball(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ phase,
+ elapsed_seconds,
+ camera_world,
+ )
+ .into_iter()
+ .collect(),
+ _ => Vec::new(),
+ };
+ let ranges = ambient_effect_ranges(slot);
+ if frames.is_empty() {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ }
+ let variable_pool = slot.kind == AmbientEffectKind::Op7ParticlePool;
+ let aligned_frames = align_ambient_effect_frames(frames, ranges.len(), variable_pool)?;
+ for ((range, material_animation_index), frame) in ranges.into_iter().zip(aligned_frames) {
+ if let Some(frame) = frame {
+ let binding = material_animations
+ .get_mut(material_animation_index)
+ .ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?;
+ binding.normalized_phase_override = Some(frame.material_phase);
+ write_ambient_effect_frame(mesh, range, &frame)?;
+ updates.push(AmbientEffectGeometryUpdate {
+ range_index: range.range_index,
+ index_count: if frame.active && frame.color[3] > 0.0 {
+ 6
+ } else {
+ 0
+ },
+ command_rgb: [frame.color[0], frame.color[1], frame.color[2]],
+ });
+ } else {
+ deactivate_ambient_effect_range(
+ range,
+ material_animation_index,
+ material_animations,
+ &mut updates,
+ )?;
+ }
+ }
+ }
+ Ok(updates)
+}
+
+fn align_ambient_effect_frames(
+ frames: Vec<fparkan_fx::fx_visual::EffectQuadFrame>,
+ range_count: usize,
+ variable_pool: bool,
+) -> Result<Vec<Option<fparkan_fx::fx_visual::EffectQuadFrame>>, String> {
+ if (variable_pool && frames.len() > range_count)
+ || (!variable_pool && frames.len() != range_count)
+ {
+ return Err(format!(
+ "ambient FX frame pool has {} particles but {range_count} draw ranges",
+ frames.len()
+ ));
+ }
+ let active_count = frames.len();
+ let mut aligned: Vec<_> = frames.into_iter().map(Some).collect();
+ aligned.extend(std::iter::repeat_with(|| None).take(range_count - active_count));
+ Ok(aligned)
+}
+
+fn deactivate_ambient_effect_slot(
+ slot: &AmbientEffectGpuSlot,
+ material_animations: &mut [MaterialAnimationBinding],
+ updates: &mut Vec<AmbientEffectGeometryUpdate>,
+) -> Result<(), String> {
+ for (range, material_animation_index) in ambient_effect_ranges(slot) {
+ deactivate_ambient_effect_range(
+ range,
+ material_animation_index,
+ material_animations,
+ updates,
+ )?;
+ }
+ Ok(())
+}
+
+fn deactivate_ambient_effect_range(
+ range: &EnvironmentGpuRange,
+ material_animation_index: usize,
+ material_animations: &mut [MaterialAnimationBinding],
+ updates: &mut Vec<AmbientEffectGeometryUpdate>,
+) -> Result<(), String> {
+ let binding = material_animations
+ .get_mut(material_animation_index)
+ .ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?;
+ binding.normalized_phase_override = Some(0.0);
+ updates.push(AmbientEffectGeometryUpdate {
+ range_index: range.range_index,
+ index_count: 0,
+ command_rgb: [0.0; 3],
+ });
+ Ok(())
+}
+
+fn write_ambient_effect_frame(
+ mesh: &mut VulkanStaticMesh,
+ range: &EnvironmentGpuRange,
+ frame: &fparkan_fx::fx_visual::EffectQuadFrame,
+) -> Result<(), String> {
+ let end = range
+ .vertex_start
+ .checked_add(range.vertex_capacity)
+ .ok_or_else(|| "ambient FX vertex range overflows".to_string())?;
+ let destination = mesh
+ .vertices
+ .get_mut(range.vertex_start..end)
+ .ok_or_else(|| "ambient FX vertex allocation is out of bounds".to_string())?;
+ if destination.len() != 4 {
+ return Err("ambient FX quad allocation changed after GPU preparation".to_string());
+ }
+ for (index, target) in destination.iter_mut().enumerate() {
+ *target = environment_vertex_with_alpha(
+ frame.vertices[index],
+ [frame.color[0], frame.color[1], frame.color[2]],
+ frame.color[3].clamp(0.0, 1.0),
+ frame.uv[index],
+ );
+ }
+ Ok(())
+}
+
+fn ambient_effect_mesh_primitive_layout(
+ frame: &fparkan_fx::fx_visual::EffectMeshFrame,
+) -> Result<Vec<AmbientEffectMeshPrimitive>, String> {
+ if frame.vertices.len() != frame.uv.len() || frame.indices.is_empty() {
+ return Err("ambient Op9 mesh has mismatched vertices/UVs or no indices".to_string());
+ }
+ let quad_count = frame
+ .indices
+ .len()
+ .checked_sub(frame.vertices.len())
+ .filter(|difference| difference.is_multiple_of(2))
+ .map(|difference| difference / 2)
+ .ok_or_else(|| "ambient Op9 mesh is not a native quad/cap layout".to_string())?;
+ let quad_vertex_count = quad_count
+ .checked_mul(4)
+ .ok_or_else(|| "ambient Op9 quad vertex count overflows".to_string())?;
+ let quad_index_count = quad_count
+ .checked_mul(6)
+ .ok_or_else(|| "ambient Op9 quad index count overflows".to_string())?;
+ let cap_vertex_count = frame
+ .vertices
+ .len()
+ .checked_sub(quad_vertex_count)
+ .filter(|count| count.is_multiple_of(3))
+ .ok_or_else(|| "ambient Op9 mesh has an invalid cap vertex count".to_string())?;
+ let cap_count = cap_vertex_count / 3;
+ let cap_index_count = cap_count
+ .checked_mul(3)
+ .ok_or_else(|| "ambient Op9 cap index count overflows".to_string())?;
+ if quad_index_count + cap_index_count != frame.indices.len() {
+ return Err("ambient Op9 quad and cap index counts do not match the frame".to_string());
+ }
+
+ // The native generator appends all quads first, then its triangle cap fan.
+ let mut primitives = Vec::with_capacity(quad_count + cap_count);
+ for quad in 0..quad_count {
+ let vertex_start = quad * 4;
+ let index_start = quad * 6;
+ let base = u16::try_from(vertex_start)
+ .map_err(|_| "ambient Op9 quad vertex exceeds u16".to_string())?;
+ let expected = [base, base + 1, base + 2, base + 2, base + 3, base];
+ if frame.indices.get(index_start..index_start + expected.len()) != Some(&expected) {
+ return Err("ambient Op9 quad topology changed from native ordering".to_string());
+ }
+ primitives.push(AmbientEffectMeshPrimitive {
+ vertex_start,
+ vertex_count: 4,
+ index_start,
+ index_count: 6,
+ });
+ }
+ for cap in 0..cap_count {
+ let vertex_start = quad_vertex_count + cap * 3;
+ let index_start = quad_index_count + cap * 3;
+ let base = u16::try_from(vertex_start)
+ .map_err(|_| "ambient Op9 cap vertex exceeds u16".to_string())?;
+ let expected = [base, base + 1, base + 2];
+ if frame.indices.get(index_start..index_start + expected.len()) != Some(&expected) {
+ return Err("ambient Op9 cap topology changed from native ordering".to_string());
+ }
+ primitives.push(AmbientEffectMeshPrimitive {
+ vertex_start,
+ vertex_count: 3,
+ index_start,
+ index_count: 3,
+ });
+ }
+ if primitives.is_empty() {
+ return Err("ambient Op9 mesh contains no native draw primitives".to_string());
+ }
+ Ok(primitives)
+}
+
+fn ambient_effect_mesh_primitive_vertices(
+ frame: &fparkan_fx::fx_visual::EffectMeshFrame,
+ primitive: AmbientEffectMeshPrimitive,
+) -> Vec<VulkanStaticVertex> {
+ let end = primitive.vertex_start + primitive.vertex_count;
+ frame.vertices[primitive.vertex_start..end]
+ .iter()
+ .zip(&frame.uv[primitive.vertex_start..end])
+ .map(|(position, uv)| {
+ environment_vertex_with_alpha(
+ *position,
+ [frame.color[0], frame.color[1], frame.color[2]],
+ frame.color[3].clamp(0.0, 1.0),
+ *uv,
+ )
+ })
+ .collect()
+}
+
+fn ambient_effect_mesh_primitive_indices(
+ frame: &fparkan_fx::fx_visual::EffectMeshFrame,
+ primitive: AmbientEffectMeshPrimitive,
+) -> Result<Vec<u32>, String> {
+ let index_end = primitive.index_start + primitive.index_count;
+ frame.indices[primitive.index_start..index_end]
+ .iter()
+ .map(|index| {
+ usize::from(*index)
+ .checked_sub(primitive.vertex_start)
+ .and_then(|local| u32::try_from(local).ok())
+ .ok_or_else(|| {
+ "ambient Op9 primitive index is outside its vertex block".to_string()
+ })
+ })
+ .collect()
+}
+
+fn write_ambient_effect_mesh_primitive(
+ mesh: &mut VulkanStaticMesh,
+ range: &EnvironmentGpuRange,
+ frame: &fparkan_fx::fx_visual::EffectMeshFrame,
+ primitive: AmbientEffectMeshPrimitive,
+) -> Result<(), String> {
+ if range.vertex_capacity != primitive.vertex_count {
+ return Err("ambient Op9 primitive vertex allocation changed".to_string());
+ }
+ let vertex_end = range
+ .vertex_start
+ .checked_add(range.vertex_capacity)
+ .ok_or_else(|| "ambient Op9 vertex range overflows".to_string())?;
+ let destination = mesh
+ .vertices
+ .get_mut(range.vertex_start..vertex_end)
+ .ok_or_else(|| "ambient Op9 vertex allocation is out of bounds".to_string())?;
+ let draw_range = mesh
+ .draw_ranges
+ .get(range.range_index)
+ .ok_or_else(|| "ambient Op9 draw range is out of bounds".to_string())?;
+ let index_start = usize::try_from(draw_range.first_index)
+ .map_err(|_| "ambient Op9 first index exceeds usize".to_string())?;
+ let index_end = index_start
+ .checked_add(primitive.index_count)
+ .ok_or_else(|| "ambient Op9 index range overflows".to_string())?;
+ let current_indices = mesh
+ .indices
+ .get(index_start..index_end)
+ .ok_or_else(|| "ambient Op9 index allocation is out of bounds".to_string())?;
+ let frame_indices = frame
+ .indices
+ .get(primitive.index_start..primitive.index_start + primitive.index_count)
+ .ok_or_else(|| "ambient Op9 source primitive index range is out of bounds".to_string())?;
+ for (current, local) in current_indices.iter().zip(frame_indices) {
+ let primitive_local = usize::from(*local)
+ .checked_sub(primitive.vertex_start)
+ .ok_or_else(|| "ambient Op9 source index leaves its primitive".to_string())?;
+ let expected = u32::try_from(range.vertex_start)
+ .ok()
+ .and_then(|base| base.checked_add(u32::try_from(primitive_local).ok()?))
+ .ok_or_else(|| "ambient Op9 primitive index exceeds u32".to_string())?;
+ if *current != expected {
+ return Err("ambient Op9 primitive topology changed after GPU preparation".to_string());
+ }
+ }
+ let frame_vertex_end = primitive
+ .vertex_start
+ .checked_add(primitive.vertex_count)
+ .ok_or_else(|| "ambient Op9 source primitive vertex range overflows".to_string())?;
+ let frame_vertices = frame
+ .vertices
+ .get(primitive.vertex_start..frame_vertex_end)
+ .ok_or_else(|| "ambient Op9 source primitive vertex range is out of bounds".to_string())?;
+ let frame_uvs = frame
+ .uv
+ .get(primitive.vertex_start..frame_vertex_end)
+ .ok_or_else(|| "ambient Op9 source primitive UV range is out of bounds".to_string())?;
+ for ((target, position), uv) in destination.iter_mut().zip(frame_vertices).zip(frame_uvs) {
+ *target = environment_vertex_with_alpha(
+ *position,
+ [frame.color[0], frame.color[1], frame.color[2]],
+ frame.color[3].clamp(0.0, 1.0),
+ *uv,
+ );
+ }
+ Ok(())
+}
+
+fn ambient_effect_vertex_rgb(command_rgb: [f32; 3], material_additive_rgb: [f32; 3]) -> [f32; 3] {
+ std::array::from_fn(|channel| command_rgb[channel] * material_additive_rgb[channel])
+}
+
+fn apply_ambient_effect_material_tints(
+ slots: &[AmbientEffectGpuSlot],
+ mesh: &mut VulkanStaticMesh,
+ updates: &[AmbientEffectGeometryUpdate],
+ active_materials: &HashMap<usize, ActiveMaterialState>,
+) -> Result<(), String> {
+ for update in updates.iter().filter(|update| update.index_count > 0) {
+ let range = slots
+ .iter()
+ .find_map(|slot| ambient_effect_range_for(slot, update.range_index))
+ .ok_or_else(|| "ambient FX draw range has no owning slot".to_string())?;
+ let material = active_materials
+ .get(&update.range_index)
+ .ok_or_else(|| "ambient FX draw range has no active MAT0 phase".to_string())?;
+ let vertex_rgb = ambient_effect_vertex_rgb(update.command_rgb, material.additive_rgb);
+ let end = range
+ .vertex_start
+ .checked_add(range.vertex_capacity)
+ .ok_or_else(|| "ambient FX vertex range overflows".to_string())?;
+ let vertices = mesh
+ .vertices
+ .get_mut(range.vertex_start..end)
+ .ok_or_else(|| "ambient FX vertex allocation is out of bounds".to_string())?;
+ for vertex in vertices {
+ vertex.color = vertex_rgb;
+ }
+ }
+ Ok(())
+}
+
+fn ambient_effect_draw_sort_keys(
+ mesh: &VulkanStaticMesh,
+ slots: &[AmbientEffectGpuSlot],
+ camera_world: [f32; 3],
+) -> Result<Vec<(usize, f32)>, String> {
+ slots.iter().try_fold(Vec::new(), |mut keys, slot| {
+ for (range, _) in ambient_effect_ranges(slot) {
+ keys.push((
+ range.range_index,
+ first_indexed_vertex_distance(mesh, range.range_index, camera_world)?,
+ ));
+ }
+ Ok(keys)
+ })
+}
+
+fn world_draw_range_indices(
+ draw_range_count: usize,
+ fixed_range_indices: &HashSet<usize>,
+) -> Vec<usize> {
+ (0..draw_range_count)
+ .filter(|range_index| !fixed_range_indices.contains(range_index))
+ .collect()
+}
+
+// TEMPORARY diagnostic for ROADMAP.md A.4 (smoke_fr_02 speed). Enabled by
+// FPARKAN_DIAG_SMOKE; identifies the emitter by its Op8 body (pool 30,
+// interval 0.08) and reports spawn rate, particle lifetime and rise speed
+// once per two seconds of effect clock. Remove after the investigation.
+#[derive(Default)]
+struct SmokeDiagnosticTrack {
+ window_start: Option<f32>,
+ last_born: f32,
+ spawns: usize,
+ calls: usize,
+ max_age: f32,
+ sum_age: f64,
+ sum_z: f64,
+ sum_age_z: f64,
+ sum_age_age: f64,
+ samples: usize,
+}
+
+fn smoke_fr_02_diagnostic(
+ slot: &AmbientEffectGpuSlot,
+ clock: f32,
+ phase: f32,
+ frames: &[fparkan_fx::fx_visual::EffectQuadFrame],
+) {
+ static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ static TRACKS: std::sync::OnceLock<
+ std::sync::Mutex<HashMap<(usize, usize, usize, usize, usize), SmokeDiagnosticTrack>>,
+ > = std::sync::OnceLock::new();
+ if !*ENABLED.get_or_init(|| std::env::var_os("FPARKAN_DIAG_SMOKE").is_some()) {
+ return;
+ }
+ let body = slot.command.raw_body.as_ref();
+ let read_u32 = |offset: usize| {
+ body.get(offset..offset + 4)
+ .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four bytes")))
+ };
+ if read_u32(0x20) != Some(30) || read_u32(0x14).map(f32::from_bits) != Some(0.08) {
+ return;
+ }
+ let key = (
+ slot.owner_object_index,
+ slot.owner_component_index,
+ slot.control_table_index,
+ slot.control_row_index,
+ slot.command_index,
+ );
+ let mut tracks = TRACKS
+ .get_or_init(|| std::sync::Mutex::new(HashMap::new()))
+ .lock()
+ .expect("smoke diagnostic lock");
+ let slot_count = tracks.len() + usize::from(!tracks.contains_key(&key));
+ let track = tracks.entry(key).or_default();
+ let window_start = *track.window_start.get_or_insert(clock);
+ track.calls += 1;
+ for frame in frames.iter().filter(|frame| frame.active) {
+ let born = clock - frame.elapsed_seconds;
+ if born > track.last_born + 1e-4 {
+ track.spawns += 1;
+ track.last_born = track.last_born.max(born);
+ }
+ let z = frame.vertices.iter().map(|vertex| vertex[2]).sum::<f32>() * 0.25;
+ let age = f64::from(frame.elapsed_seconds);
+ track.max_age = track.max_age.max(frame.elapsed_seconds);
+ track.sum_age += age;
+ track.sum_z += f64::from(z);
+ track.sum_age_z += age * f64::from(z);
+ track.sum_age_age += age * age;
+ track.samples += 1;
+ }
+ let window = clock - window_start;
+ if window < 2.0 {
+ return;
+ }
+ let n = track.samples as f64;
+ let rise_per_second = if n > 1.0 {
+ (n * track.sum_age_z - track.sum_age * track.sum_z)
+ / (n * track.sum_age_age - track.sum_age * track.sum_age)
+ } else {
+ 0.0
+ };
+ let axis_lengths: [f32; 3] = std::array::from_fn(|column| {
+ (0..3)
+ .map(|row| slot.world_from_effect[row][column].powi(2))
+ .sum::<f32>()
+ .sqrt()
+ });
+ eprintln!(
+ "smoke_diag slots={slot_count} key={key:?} clock={clock:.3} window={window:.3}s calls={} calls_per_s={:.1} spawns={} spawns_per_s={:.2} active_now={} max_age={:.3}s rise_per_s={rise_per_second:.2} rise_per_life_2_4s={:.1} phase={phase:.3} emitter=[{:.2},{:.2},{:.2}] effect_axes={axis_lengths:.3?}",
+ track.calls,
+ track.calls as f32 / window,
+ track.spawns,
+ track.spawns as f32 / window,
+ frames.iter().filter(|frame| frame.active).count(),
+ track.max_age,
+ rise_per_second * 2.4,
+ slot.world_from_effect[0][3],
+ slot.world_from_effect[1][3],
+ slot.world_from_effect[2][3],
+ );
+ *track = SmokeDiagnosticTrack {
+ window_start: Some(clock),
+ last_born: track.last_born,
+ ..SmokeDiagnosticTrack::default()
+ };
+}
+
+// TEMPORARY diagnostic for ROADMAP.md A.4 (animal "aura" speed). Enabled by
+// FPARKAN_DIAG_FX_MATERIAL=<material name>, for example smoke_yg. Reports the
+// effect-phase rate, call rate, particle lifetime and rise per particle life
+// for every Op7/Op8 slot whose command uses that MAT0. Remove after the
+// investigation.
+#[derive(Default)]
+struct FxSpeedDiagnosticTrack {
+ window_start: Option<f32>,
+ calls: usize,
+ phase_travel: f32,
+ last_phase: Option<f32>,
+ max_age: f32,
+ sum_t: f64,
+ sum_z: f64,
+ sum_tz: f64,
+ sum_tt: f64,
+ samples: usize,
+}
+
+fn fx_material_speed_diagnostic(
+ slot: &AmbientEffectGpuSlot,
+ material_name_offset: usize,
+ clock: f32,
+ phase: f32,
+ frames: &[fparkan_fx::fx_visual::EffectQuadFrame],
+) {
+ static WANTED: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
+ static TRACKS: std::sync::OnceLock<
+ std::sync::Mutex<HashMap<(usize, usize, usize, usize, usize), FxSpeedDiagnosticTrack>>,
+ > = std::sync::OnceLock::new();
+ let Some(wanted) = WANTED
+ .get_or_init(|| {
+ std::env::var("FPARKAN_DIAG_FX_MATERIAL")
+ .ok()
+ .map(|name| name.to_ascii_lowercase())
+ })
+ .as_deref()
+ else {
+ return;
+ };
+ let body = slot.command.raw_body.as_ref();
+ let Some(name) = body.get(material_name_offset..material_name_offset + 32) else {
+ return;
+ };
+ let name = name.split(|byte| *byte == 0).next().unwrap_or_default();
+ if !name.eq_ignore_ascii_case(wanted.as_bytes()) {
+ return;
+ }
+ let key = (
+ slot.owner_object_index,
+ slot.owner_component_index,
+ slot.control_table_index,
+ slot.control_row_index,
+ slot.command_index,
+ );
+ let mut tracks = TRACKS
+ .get_or_init(|| std::sync::Mutex::new(HashMap::new()))
+ .lock()
+ .expect("fx diagnostic lock");
+ let slot_count = tracks.len() + usize::from(!tracks.contains_key(&key));
+ let track = tracks.entry(key).or_default();
+ let window_start = *track.window_start.get_or_insert(clock);
+ track.calls += 1;
+ if let Some(last_phase) = track.last_phase {
+ let delta = phase - last_phase;
+ track.phase_travel += if delta < -0.5 { delta + 1.0 } else { delta };
+ }
+ track.last_phase = Some(phase);
+ for frame in frames.iter().filter(|frame| frame.active) {
+ // Op8 reports the particle age in seconds; Op7 reports its normalized
+ // particle age, so the regression is per second or per life.
+ let t = if slot.command.opcode == FxOpcode::Op8 {
+ frame.elapsed_seconds
+ } else {
+ frame.normalized_time
+ };
+ let z = frame.vertices.iter().map(|vertex| vertex[2]).sum::<f32>() * 0.25;
+ track.max_age = track.max_age.max(t);
+ track.sum_t += f64::from(t);
+ track.sum_z += f64::from(z);
+ track.sum_tz += f64::from(t) * f64::from(z);
+ track.sum_tt += f64::from(t) * f64::from(t);
+ track.samples += 1;
+ }
+ let window = clock - window_start;
+ if window < 3.0 {
+ return;
+ }
+ let n = track.samples as f64;
+ let slope = if n > 1.0 {
+ (n * track.sum_tz - track.sum_t * track.sum_z)
+ / (n * track.sum_tt - track.sum_t * track.sum_t)
+ } else {
+ 0.0
+ };
+ eprintln!(
+ "fx_diag material={wanted} op={:?} slots={slot_count} key={key:?} clock={clock:.3} window={window:.3}s calls_per_s={:.1} phase_rate_per_s={:.4} active_now={} max_age={:.3} rise_slope={slope:.2} ({}) duration={:.2} mode={} owner=[{:.1},{:.1},{:.1}]",
+ slot.command.opcode,
+ track.calls as f32 / window,
+ track.phase_travel / window,
+ frames.iter().filter(|frame| frame.active).count(),
+ track.max_age,
+ if slot.command.opcode == FxOpcode::Op8 { "units/s, age in s" } else { "units/life, age normalized" },
+ slot.duration_seconds,
+ slot.time_mode,
+ slot.world_from_effect[0][3],
+ slot.world_from_effect[1][3],
+ slot.world_from_effect[2][3],
+ );
+ *track = FxSpeedDiagnosticTrack {
+ window_start: Some(clock),
+ last_phase: track.last_phase,
+ ..FxSpeedDiagnosticTrack::default()
+ };
+}
+
+fn ambient_effect_phase(
+ time_mode: u32,
+ header_flags: u32,
+ elapsed_seconds: f32,
+ duration_seconds: f32,
+) -> Option<f32> {
+ if !elapsed_seconds.is_finite()
+ || elapsed_seconds < 0.0
+ || !duration_seconds.is_finite()
+ || duration_seconds < 0.0
+ {
+ return None;
+ }
+ let mut phase = match time_mode {
+ 0 => 0.0,
+ // The native phase stays raw through the optional 0x200/0x20
+ // transforms; the command-level 0x8120 path clamps only afterward.
+ 1 if duration_seconds > 0.0 => elapsed_seconds / duration_seconds,
+ 2 if duration_seconds > 0.0 => {
+ elapsed_seconds.rem_euclid(duration_seconds) / duration_seconds
+ }
+ _ => return None,
+ };
+ if header_flags & 0x200 != 0 && duration_seconds > 0.0 {
+ phase *= elapsed_seconds / duration_seconds;
+ }
+ if header_flags & 0x20 != 0 {
+ phase = if phase >= 0.5 {
+ (1.0 - phase) * 2.0
+ } else {
+ phase * 2.0
+ };
+ }
+ Some(phase)
+}
+
fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
let [x, y, z, w] = rotation;
let tx = 2.0 * (y * value[2] - z * value[1]);
@@ -4217,163 +7017,890 @@ fn initial_control_gate_values(control: &PreparedComponentControl) -> Vec<Option
.collect()
}
-fn control_has_tag21(control: &PreparedComponentControl) -> bool {
- control.typed_records.iter().any(|record| record.tag == 21)
+fn control_clock_ms_for_frame(target_ms: u32, frame_index: u64, target_frames: u64) -> u32 {
+ if target_frames <= 1 {
+ return target_ms;
+ }
+ let last_frame = u128::from(target_frames - 1);
+ let frame = u128::from(frame_index).min(last_frame);
+ ((u128::from(target_ms) * frame) / last_frame) as u32
}
-fn component_chain_has_tag21(
- tree: Option<&[UnitComponentTreeNode]>,
- controls: &[Option<PreparedComponentControl>],
+fn replay_environment_delta_seconds(last_applied_ms: u32, current_ms: u32) -> f32 {
+ current_ms.saturating_sub(last_applied_ms) as f32 / 1_000.0
+}
+
+fn control_clock_mode_label(target_ms: Option<u32>) -> String {
+ match target_ms {
+ None => "realtime".to_string(),
+ Some(0) => "fixed-0ms".to_string(),
+ Some(target_ms) => format!("ramp-0-to-{target_ms}ms"),
+ }
+}
+
+fn write_readback_sequence_frame(
+ directory: &Path,
+ frame_index: u64,
+ control_clock_ms: u32,
+ artifact: &VulkanReadbackArtifact,
+) -> Result<PathBuf, String> {
+ std::fs::create_dir_all(directory).map_err(|error| {
+ format!(
+ "create readback sequence directory {}: {error}",
+ directory.display()
+ )
+ })?;
+ let path = directory.join(format!(
+ "frame-{frame_index:06}-clock-{control_clock_ms:010}ms-{}x{}.rgba",
+ artifact.width, artifact.height,
+ ));
+ let mut file = std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(&path)
+ .map_err(|error| format!("create readback frame {}: {error}", path.display()))?;
+ file.write_all(&artifact.bytes)
+ .map_err(|error| format!("write readback frame {}: {error}", path.display()))?;
+ Ok(path)
+}
+
+fn quaternion_yaw_z_degrees(rotation: [f32; 4]) -> Option<f32> {
+ if !rotation.iter().all(|value| value.is_finite()) {
+ return None;
+ }
+ let [x, y, z, w] = rotation;
+ let radians = (2.0 * (w * z + x * y)).atan2(1.0 - 2.0 * (y * y + z * z));
+ radians.is_finite().then_some(radians.to_degrees())
+}
+
+fn emit_control_animation_capture_diagnostics(
+ instances: &[Vec<Option<ControlAnimationInstance>>],
+ pose_buffers: &[Vec<Option<NodePoseBuffer>>],
+ control_clock_ms: u32,
+) {
+ for (object_index, components) in instances.iter().enumerate() {
+ for (component_index, instance) in components.iter().enumerate() {
+ let Some(instance) = instance.as_ref() else {
+ continue;
+ };
+ let mut gate_index = 0;
+ for record in instance
+ .control
+ .typed_records
+ .iter()
+ .filter(|record| record.tag == 21)
+ {
+ let mapped_row = record
+ .values
+ .first()
+ .and_then(|row| usize::try_from(*row).ok());
+ let timeline = mapped_row.and_then(|row| instance.control.timeline_rows.get(row));
+ let node_index = timeline.map(|row| row.node_index);
+ let sampled_time = node_index
+ .and_then(|node| instance.last_sampled_node_times.get(node))
+ .copied()
+ .flatten();
+ let pose = pose_buffers
+ .get(object_index)
+ .and_then(|items| items.get(component_index))
+ .and_then(Option::as_ref)
+ .and_then(|hierarchy| node_index.and_then(|node| hierarchy.poses.get(node)));
+ let rotation = pose.map(|pose| pose.rotation);
+ let yaw_z_degrees = rotation.and_then(quaternion_yaw_z_degrees);
+ let gate_value = instance.gate_values.get(gate_index).copied().flatten();
+ eprintln!(
+ "control_animation_capture object={object_index} component={component_index} clock_ms={control_clock_ms} gate_value={gate_value:?} gate_provider={:?} row={mapped_row:?} node={node_index:?} sampled_time={sampled_time:?} rotation={rotation:?} yaw_z_degrees={yaw_z_degrees:?}",
+ record.gate_provider,
+ );
+ gate_index += 1;
+ }
+ }
+ }
+}
+
+fn update_control_animation_pose_buffers(
+ instances: &mut [Vec<Option<ControlAnimationInstance>>],
+ pose_buffers: &mut [Vec<Option<NodePoseBuffer>>],
+ native_clock_ms: u32,
+) -> Result<(), String> {
+ for (object_index, components) in instances.iter_mut().enumerate() {
+ let Some(object_pose_buffers) = pose_buffers.get_mut(object_index) else {
+ continue;
+ };
+ for (component_index, instance) in components.iter_mut().enumerate() {
+ let Some(instance) = instance.as_mut() else {
+ continue;
+ };
+ let node_times: Vec<Option<AnimationTime>> = match sample_control_node_times(
+ &instance.control,
+ native_clock_ms,
+ &instance.gate_values,
+ &mut instance.state,
+ ) {
+ Ok(times) => {
+ instance.last_sampled_node_times = times.clone();
+ times
+ .into_iter()
+ .map(|time| time.map(AnimationTime))
+ .collect()
+ }
+ Err(
+ error @ (ControlSamplingError::UnsupportedTag21Mode { .. }
+ | ControlSamplingError::UnsupportedVariableMapping { .. }
+ | ControlSamplingError::MissingGateProvider { .. }
+ | ControlSamplingError::GateProviderKeyOutOfRange { .. }
+ | ControlSamplingError::MissingGateProviderValue { .. }
+ | ControlSamplingError::GateProviderCountMismatch { .. }),
+ ) => {
+ if !instance.reported_unsupported_fallback {
+ eprintln!(
+ "control_animation_fallback object={object_index} component={component_index} error={error:?}"
+ );
+ instance.reported_unsupported_fallback = true;
+ }
+ let fallback_times = static_preview_node_times(
+ instance.control.model_node_count,
+ &instance.control.bindings,
+ )
+ .map_err(|fallback| {
+ format!(
+ "control animation fallback for object {object_index} component {component_index}: {fallback}"
+ )
+ })?;
+ instance.last_sampled_node_times = fallback_times
+ .iter()
+ .map(|time| time.map(|time| time.0))
+ .collect();
+ fallback_times
+ }
+ Err(error) => {
+ return Err(format!(
+ "control animation sample for object {object_index} component {component_index}: {error:?}"
+ ));
+ }
+ };
+ let hierarchy = node38_sampled_hierarchy_at_times(&instance.model, &node_times)
+ .or_else(|| node38_fallback_hierarchy(&instance.model));
+ if let Some(hierarchy) = hierarchy {
+ if let Some(pose_buffer) = object_pose_buffers.get_mut(component_index) {
+ *pose_buffer = Some(hierarchy);
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+fn control_tree_chain_has_tag21(
component_index: usize,
+ tree: Option<&[fparkan_prototype::UnitComponentTreeNode]>,
+ controls: &[Option<PreparedComponentControl>],
) -> bool {
- let Some(tree) = tree else {
- return false;
- };
let mut current = Some(component_index);
- for _ in 0..=tree.len() {
- let Some(index) = current else {
- return false;
- };
+ let mut remaining = controls.len();
+ while let Some(index) = current {
if controls
.get(index)
.and_then(Option::as_ref)
- .is_some_and(control_has_tag21)
+ .is_some_and(|control| control.typed_records.iter().any(|record| record.tag == 21))
{
return true;
}
- current = tree.get(index).and_then(|node| node.parent_index);
+ if remaining == 0 {
+ return false;
+ }
+ remaining -= 1;
+ current = tree
+ .and_then(|nodes| nodes.get(index))
+ .and_then(|node| node.parent_index);
}
false
}
-fn refresh_dynamic_component_mounts(
- mounts: &[DynamicComponentMount],
+fn update_dynamic_component_geometry(
+ geometry: &[Vec<Option<DynamicComponentGeometry>>],
pose_buffers: &[Vec<Option<NodePoseBuffer>>],
mount_poses: &mut [Vec<Option<VulkanNodePose>>],
-) -> Result<bool, String> {
- let mut changed = false;
- for mount in mounts {
- let parent_mounted = mount_poses
- .get(mount.object_index)
- .and_then(|poses| poses.get(mount.parent_index))
- .copied()
- .flatten();
- let parent_hierarchy = pose_buffers
- .get(mount.object_index)
- .and_then(|buffers| buffers.get(mount.parent_index))
- .and_then(Option::as_ref)
- .ok_or_else(|| {
- format!(
- "dynamic component {} parent {} has no sampled Node38 hierarchy",
- mount.component_index, mount.parent_index
- )
+ mesh: &mut VulkanStaticMesh,
+) -> Result<(), String> {
+ let identity_pose = VulkanNodePose {
+ translation: [0.0; 3],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ };
+ for (object_index, components) in geometry.iter().enumerate() {
+ for (component_index, component) in components.iter().enumerate() {
+ let Some(component) = component else {
+ continue;
+ };
+ if let Some((parent_index, socket)) = component.parent_mount {
+ let parent_mounted = mount_poses
+ .get(object_index)
+ .and_then(|items| items.get(parent_index))
+ .ok_or_else(|| {
+ format!("dynamic component {object_index}:{component_index} has no parent mount slot")
+ })?
+ .is_some();
+ let parent_hierarchy = pose_buffers
+ .get(object_index)
+ .and_then(|items| items.get(parent_index))
+ .and_then(Option::as_ref)
+ .ok_or_else(|| {
+ format!(
+ "dynamic component {object_index}:{component_index} has no parent pose"
+ )
+ })?;
+ let parent_socket = if parent_mounted {
+ node38_pose_relative_to_root_from_hierarchy(parent_hierarchy, socket)
+ } else {
+ node38_pose_from_hierarchy(parent_hierarchy, socket)
+ }
+ .ok_or_else(|| {
+ format!("dynamic component {object_index}:{component_index} parent socket {socket} is unavailable")
+ })?;
+ let parent_root = mount_poses
+ .get(object_index)
+ .and_then(|items| items.get(parent_index))
+ .copied()
+ .flatten()
+ .unwrap_or(identity_pose);
+ let mount = mount_poses
+ .get_mut(object_index)
+ .and_then(|items| items.get_mut(component_index))
+ .ok_or_else(|| {
+ format!(
+ "dynamic component {object_index}:{component_index} has no mount slot"
+ )
+ })?;
+ *mount = Some(compose_unit_pose(parent_root, parent_socket));
+ }
+ let hierarchy = pose_buffers
+ .get(object_index)
+ .and_then(|items| items.get(component_index))
+ .and_then(Option::as_ref)
+ .ok_or_else(|| {
+ format!(
+ "dynamic component {object_index}:{component_index} has no sampled pose"
+ )
+ })?;
+ let mount = mount_poses
+ .get(object_index)
+ .and_then(|items| items.get(component_index))
+ .copied()
+ .flatten();
+ let projected = match mount {
+ Some(mount) => {
+ project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
+ &component.model,
+ component.root_transform,
+ component.owner_scale,
+ hierarchy,
+ mount,
+ )
+ }
+ None => project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
+ &component.model,
+ component.root_transform,
+ component.owner_scale,
+ hierarchy,
+ ),
+ }
+ .map_err(|error| {
+ format!("reproject dynamic component {object_index}:{component_index}: {error}")
})?;
- let socket_pose = if parent_mounted.is_some() {
- node38_pose_relative_to_root_from_hierarchy(parent_hierarchy, mount.socket)
- } else {
- node38_pose_from_hierarchy(parent_hierarchy, mount.socket)
+ if projected.vertices.len() != component.vertex_count {
+ return Err(format!(
+ "dynamic component {object_index}:{component_index} changed vertex count from {} to {}",
+ component.vertex_count,
+ projected.vertices.len()
+ ));
+ }
+ let end = component
+ .vertex_start
+ .checked_add(component.vertex_count)
+ .filter(|end| *end <= mesh.vertices.len())
+ .ok_or_else(|| {
+ format!(
+ "dynamic component {object_index}:{component_index} vertex span is invalid"
+ )
+ })?;
+ mesh.vertices[component.vertex_start..end].copy_from_slice(&projected.vertices);
}
- .ok_or_else(|| {
- format!(
- "dynamic component {} mount socket {} is absent from parent {}",
- mount.component_index, mount.socket, mount.parent_index
- )
- })?;
- let root_pose = parent_mounted.unwrap_or(VulkanNodePose {
- translation: [0.0; 3],
- rotation: [0.0, 0.0, 0.0, 1.0],
- });
- let next_pose = compose_unit_pose(root_pose, socket_pose);
- let slot = mount_poses
- .get_mut(mount.object_index)
- .and_then(|poses| poses.get_mut(mount.component_index))
- .ok_or_else(|| {
- format!(
- "dynamic component {} mount slot is outside object {}",
- mount.component_index, mount.object_index
- )
- })?;
- if *slot != Some(next_pose) {
- *slot = Some(next_pose);
- changed = true;
+ }
+ Ok(())
+}
+
+struct MissionBasementFootprint {
+ object_index: usize,
+ outer: Vec<[f32; 2]>,
+ inner: Vec<[f32; 3]>,
+ flatten_inner_to_mean_outer_z: bool,
+}
+
+fn apply_basement_placement_deltas(
+ roots: &[MissionObjectDraft],
+ footprints: &[MissionBasementFootprint],
+ placements: &[BasementPlacement],
+) -> Result<Vec<MissionObjectDraft>, String> {
+ if footprints.len() != placements.len() {
+ return Err("basement placement results are not aligned with mission roots".to_string());
+ }
+ let mut adjusted_roots = roots.to_vec();
+ for (footprint, placement) in footprints.iter().zip(placements) {
+ let root = adjusted_roots
+ .get_mut(footprint.object_index)
+ .ok_or_else(|| "basement footprint references an unknown mission root".to_string())?;
+ root.position = root.root_position_with_basement_delta(placement.placement_delta);
+ }
+ Ok(adjusted_roots)
+}
+
+fn mission_basement_footprints(
+ assets: &MissionAssets,
+ roots: &[MissionObjectDraft],
+) -> Result<Vec<MissionBasementFootprint>, String> {
+ let mut footprints = Vec::new();
+ for (object_index, mission_root) in roots.iter().enumerate() {
+ let Some(mode) = mission_root.basement_placement_mode() else {
+ continue;
+ };
+ let Some(component_bas) = assets
+ .object_component_bas
+ .get(object_index)
+ .and_then(|components| components.first())
+ .and_then(Option::as_ref)
+ else {
+ continue;
+ };
+ let geometry = &component_bas.geometry;
+ let bas_label = format!(
+ "{}:{}",
+ component_bas.source.archive.as_str(),
+ String::from_utf8_lossy(&component_bas.source.name.0)
+ );
+ // shortcut: the installed Iron Strategy corpus has one inner and one
+ // outer ring per eligible DAT. Native iterates both arrays, so reject
+ // unsupported multiplicity until the multi-ring semantics are ported.
+ if geometry.first.len() != 1 || geometry.second.len() != 1 {
+ return Err(format!(
+ "eligible mission object {object_index} from {bas_label} has {} inner and {} outer BAS rings; one of each is supported",
+ geometry.first.len(),
+ geometry.second.len()
+ ));
}
+ let transform = LegacyIron3dEulerTransform {
+ translation: mission_root.position,
+ orientation_radians: mission_root.orientation_raw,
+ };
+ let transform_points = |points: &[[f32; 3]], ring_name: &str| {
+ let mut transformed_points = Vec::with_capacity(points.len());
+ for &point in points {
+ let transformed = transform
+ .try_transform_scaled_point(point, mission_root.scale)
+ .ok_or_else(|| {
+ format!(
+ "building BAS {ring_name} ring on mission object {object_index} from {bas_label} has a non-finite transformed point"
+ )
+ })?;
+ transformed_points.push(transformed);
+ }
+ Ok::<_, String>(transformed_points)
+ };
+ let inner_record = &geometry.first[0];
+ let outer_record = &geometry.second[0];
+ for (ring_name, declared_count, points) in [
+ ("inner", inner_record.declared_count, &inner_record.points),
+ ("outer", outer_record.declared_count, &outer_record.points),
+ ] {
+ let expected_point_count = usize::try_from(declared_count)
+ .ok()
+ .and_then(|count| count.checked_add(1))
+ .ok_or_else(|| format!("building BAS {ring_name} count overflows: {bas_label}"))?;
+ if usize::try_from(declared_count).unwrap_or(0) < 3
+ || points.len() != expected_point_count
+ {
+ return Err(format!(
+ "building BAS {ring_name} ring on mission object {object_index} from {bas_label} has count {declared_count} and {} points",
+ points.len()
+ ));
+ }
+ }
+ let inner = transform_points(&inner_record.points, "inner")?;
+ let outer_3d = transform_points(&outer_record.points, "outer")?;
+ footprints.push(MissionBasementFootprint {
+ object_index,
+ outer: outer_3d.iter().map(|point| [point[0], point[1]]).collect(),
+ inner,
+ flatten_inner_to_mean_outer_z: mode == BasementPlacementMode::FlattenInnerAndAdjustRoot,
+ });
}
- Ok(changed)
+ Ok(footprints)
}
-fn static_preview_control_pose_buffer(
- model: &ModelAsset,
- control: &PreparedComponentControl,
- native_clock_ms: u32,
- state: &mut ControlInstanceState,
-) -> Result<Option<NodePoseBuffer>, String> {
- if control.model_node_count != model.node_count {
+fn interpolate_static_terrain_vertex(
+ position: [f32; 3],
+ source: [VulkanStaticVertex; 3],
+) -> Result<VulkanStaticVertex, String> {
+ let point = [f64::from(position[0]), f64::from(position[1])];
+ let a = [
+ f64::from(source[0].position[0]),
+ f64::from(source[0].position[1]),
+ ];
+ let b = [
+ f64::from(source[1].position[0]),
+ f64::from(source[1].position[1]),
+ ];
+ let c = [
+ f64::from(source[2].position[0]),
+ f64::from(source[2].position[1]),
+ ];
+ let denominator = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
+ if !denominator.is_finite() || denominator.abs() <= 1.0e-10 {
+ return Err("terrain source triangle has no stable XY barycentric coordinates".to_string());
+ }
+ let second_weight =
+ ((point[0] - a[0]) * (c[1] - a[1]) - (point[1] - a[1]) * (c[0] - a[0])) / denominator;
+ let third_weight =
+ ((b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (point[0] - a[0])) / denominator;
+ let weights = [
+ 1.0 - second_weight - third_weight,
+ second_weight,
+ third_weight,
+ ];
+ if !weights.iter().all(|weight| weight.is_finite()) {
+ return Err("terrain split vertex has invalid barycentric coordinates".to_string());
+ }
+ let blend2 = |values: [[f32; 2]; 3]| {
+ [
+ (0..3)
+ .map(|index| values[index][0] * weights[index] as f32)
+ .sum(),
+ (0..3)
+ .map(|index| values[index][1] * weights[index] as f32)
+ .sum(),
+ ]
+ };
+ let blend3 = |values: [[f32; 3]; 3]| {
+ std::array::from_fn(|axis| {
+ (0..3)
+ .map(|index| values[index][axis] * weights[index] as f32)
+ .sum()
+ })
+ };
+ let overlay_alpha = (0..3)
+ .map(|index| source[index].overlay_alpha * weights[index] as f32)
+ .sum::<f32>();
+ if !overlay_alpha.is_finite() {
return Err(format!(
- "control model node count {} does not match MSH node count {}",
- control.model_node_count, model.node_count
+ "terrain split vertex overlay alpha is non-finite: position={position:?}, weights={weights:?}, source_position={:?}, source_alpha={:?}, result={overlay_alpha}",
+ source.map(|vertex| vertex.position),
+ source.map(|vertex| vertex.overlay_alpha)
));
}
- let gate_values = initial_control_gate_values(control);
- let sampled_times = match sample_control_node_times(
- control,
- native_clock_ms,
- &gate_values,
- state,
- ) {
- Ok(times) => times,
- Err(
- error @ (ControlSamplingError::UnsupportedTag21Mode { .. }
- | ControlSamplingError::UnsupportedVariableMapping { .. }
- | ControlSamplingError::MissingGateProvider { .. }
- | ControlSamplingError::GateProviderKeyOutOfRange { .. }
- | ControlSamplingError::MissingGateProviderValue { .. }
- | ControlSamplingError::GateProviderCountMismatch { .. }),
- ) => {
- return static_preview_component_pose_buffer(model, &control.bindings, None)
- .map_err(|fallback| {
- format!(
- "control timeline sample failed ({error:?}) and static CTLD fallback failed: {fallback}"
- )
- });
+ const OVERLAY_ALPHA_ROUNDING_TOLERANCE: f32 = 1.0e-5;
+ if overlay_alpha < -OVERLAY_ALPHA_ROUNDING_TOLERANCE
+ || overlay_alpha > 1.0 + OVERLAY_ALPHA_ROUNDING_TOLERANCE
+ {
+ return Err(format!(
+ "terrain split vertex overlay alpha exceeds barycentric rounding tolerance: position={position:?}, weights={weights:?}, source_position={:?}, source_alpha={:?}, result={overlay_alpha}",
+ source.map(|vertex| vertex.position),
+ source.map(|vertex| vertex.overlay_alpha)
+ ));
+ }
+ let vertex = VulkanStaticVertex {
+ position,
+ color: blend3(source.map(|vertex| vertex.color)),
+ normal: blend3(source.map(|vertex| vertex.normal)),
+ uv: blend2(source.map(|vertex| vertex.uv)),
+ detail_uv: blend2(source.map(|vertex| vertex.detail_uv)),
+ overlay_alpha: overlay_alpha.clamp(0.0, 1.0),
+ };
+ if !vertex
+ .position
+ .iter()
+ .chain(vertex.color.iter())
+ .chain(vertex.normal.iter())
+ .chain(vertex.uv.iter())
+ .chain(vertex.detail_uv.iter())
+ .chain(std::iter::once(&vertex.overlay_alpha))
+ .all(|value| value.is_finite())
+ {
+ return Err(
+ "terrain split vertex contains a non-finite interpolated attribute".to_string(),
+ );
+ }
+ Ok(vertex)
+}
+
+fn project_basement_foundation(
+ terrain: &fparkan_terrain_format::LandMeshDocument,
+ source_mesh: &VulkanStaticMesh,
+ foundation: &mut BasementFoundation,
+) -> Result<(VulkanStaticMesh, Vec<ShadowTriangle>), String> {
+ let pipeline_state = source_mesh
+ .draw_ranges
+ .first()
+ .map(|range| range.pipeline_state)
+ .ok_or_else(|| "terrain source mesh has no LOD0 draw ranges".to_string())?;
+ let (normals, uv0_words) = canonicalize_basement_band_vertices(foundation)?;
+ let mut mesh = VulkanStaticMesh {
+ vertices: Vec::new(),
+ indices: Vec::new(),
+ draw_ranges: Vec::new(),
+ };
+ let mut shadow_receivers = Vec::with_capacity(foundation.fragments.len());
+
+ for (fragment_index, fragment) in foundation.fragments.iter().enumerate() {
+ let (material_index, base_only, vertices) = match fragment.source_face {
+ Some(face_index) => {
+ let face = terrain.faces.get(face_index).ok_or_else(|| {
+ format!("foundation fragment references unknown terrain face {face_index}")
+ })?;
+ if face.flags != fragment.surface_mask {
+ return Err(format!(
+ "foundation fragment for terrain face {face_index} changed its surface mask"
+ ));
+ }
+ let source_vertices: [VulkanStaticVertex; 3] = face
+ .vertices
+ .map(|vertex| {
+ source_mesh
+ .vertices
+ .get(usize::from(vertex))
+ .copied()
+ .ok_or_else(|| {
+ format!(
+ "terrain face {face_index} references an unknown projected vertex"
+ )
+ })
+ })
+ .into_iter()
+ .collect::<Result<Vec<_>, _>>()?
+ .try_into()
+ .map_err(|_| "terrain face did not have three vertices".to_string())?;
+ let vertices = fragment
+ .positions
+ .map(|position| {
+ source_vertices
+ .iter()
+ .find(|vertex| vertex.position == position)
+ .copied()
+ .map(Ok)
+ .unwrap_or_else(|| {
+ interpolate_static_terrain_vertex(position, source_vertices)
+ })
+ })
+ .into_iter()
+ .collect::<Result<Vec<_>, _>>()?
+ .try_into()
+ .map_err(|_| {
+ "foundation source fragment did not have three vertices".to_string()
+ })?;
+ (
+ face.material_tag,
+ fragment.surface_mask.0 & 0x400 == 0,
+ vertices,
+ )
+ }
+ None => {
+ let fragment_normals = normals
+ .get(fragment_index)
+ .and_then(Option::as_ref)
+ .ok_or_else(|| "basement band face has no native vertex normals".to_string())?;
+ let fragment_uv0_words = uv0_words
+ .get(fragment_index)
+ .and_then(Option::as_ref)
+ .ok_or_else(|| "basement band face lost its original UV words".to_string())?;
+ let vertices = basement_band_vertices(
+ fragment.positions,
+ fragment_uv0_words,
+ fragment_normals,
+ );
+ (0xff00, true, vertices)
+ }
+ };
+
+ let first_index = u32::try_from(mesh.indices.len())
+ .map_err(|_| "basement terrain index buffer exceeds u32".to_string())?;
+ for vertex in vertices {
+ let index = u32::try_from(mesh.vertices.len())
+ .map_err(|_| "basement terrain vertex buffer exceeds u32".to_string())?;
+ mesh.vertices.push(vertex);
+ mesh.indices.push(index);
}
- Err(error) => {
- return Err(format!("control timeline sample failed: {error:?}"));
+ if let Some(range) = mesh.draw_ranges.last_mut().filter(|range| {
+ range.material_index == material_index && range.terrain_base_only == base_only
+ }) {
+ range.index_count = range
+ .index_count
+ .checked_add(3)
+ .ok_or_else(|| "basement terrain draw range exceeds u32".to_string())?;
+ } else {
+ mesh.draw_ranges.push(VulkanStaticDrawRange {
+ first_index,
+ index_count: 3,
+ material_index,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state,
+ terrain_base_only: base_only,
+ alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
+ });
}
- };
- let node_times = sampled_times
- .into_iter()
- .map(|time| time.map(AnimationTime))
- .collect::<Vec<_>>();
- Ok(node38_sampled_hierarchy_at_times(model, &node_times)
- .or_else(|| node38_fallback_hierarchy(model)))
+ shadow_receivers.push(shadow_triangle(fragment.positions, fragment.surface_mask.0));
+ }
+
+ Ok((mesh, shadow_receivers))
}
-fn project_dynamic_component_geometry(
- dynamic: &DynamicComponentGeometry,
- pose: &NodePoseBuffer,
- mount: Option<VulkanNodePose>,
-) -> Result<VulkanStaticMesh, String> {
- let projected = if let Some(mount) = mount {
- project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
- &dynamic.model,
- dynamic.transform,
- dynamic.scale,
- pose,
- mount,
- )
- } else {
- project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
- &dynamic.model,
- dynamic.transform,
- dynamic.scale,
- pose,
- )
+fn prepare_static_terrain_geometry(
+ terrain: &fparkan_terrain_format::LandMeshDocument,
+ source_mesh: VulkanStaticMesh,
+ terrain_world: &TerrainWorld,
+ foundation: Option<BasementFoundation>,
+) -> Result<(VulkanStaticMesh, Vec<ShadowTriangle>, TerrainWorld), String> {
+ let Some(mut foundation) = foundation else {
+ return Ok((
+ source_mesh,
+ terrain_shadow_receivers(terrain)?,
+ terrain_world.clone(),
+ ));
};
- projected.map_err(|error| {
- format!(
- "update mission object {} component {} geometry: {error}",
- dynamic.object_index, dynamic.component_index
- )
+ let (mesh, shadow_receivers) =
+ project_basement_foundation(terrain, &source_mesh, &mut foundation)?;
+ let terrain_query = terrain_world
+ .clone()
+ .with_basement_foundation(foundation)
+ .map_err(|error| format!("install canonical terrain query surfaces: {error}"))?;
+ Ok((mesh, shadow_receivers, terrain_query))
+}
+
+#[derive(Clone, Copy, Debug)]
+struct BasementBandVertex {
+ position: [f32; 3],
+ uv0: [i16; 2],
+ // The native callback leaves UV1 unwritten; zero is a deterministic
+ // assumption for matching generated vertices and is not native parity.
+ detail_uv: [i16; 2],
+ alpha: f32,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct BasementBandVertexGroup {
+ position: [f32; 3],
+ uv0: [i16; 2],
+ detail_uv: [i16; 2],
+ alpha: f32,
+ face_normal_sum: [i64; 3],
+}
+
+fn canonicalize_basement_band_vertices(
+ foundation: &mut BasementFoundation,
+) -> Result<(Vec<Option<[[f32; 3]; 3]>>, Vec<Option<[[i16; 2]; 3]>>), String> {
+ let mut group_ids_by_fragment = vec![None; foundation.fragments.len()];
+ let mut uv0_words_by_fragment = vec![None; foundation.fragments.len()];
+ let mut groups = Vec::<BasementBandVertexGroup>::new();
+ let mut buckets = HashMap::<(i32, i32, i16, i16), Vec<usize>>::new();
+
+ // shortcut: installed-corpus probing found no exact UV0 weld key between
+ // generated bands and clipped source fragments. Extend the index to source
+ // vertices if a future corpus reaches that key.
+ for (fragment_index, fragment) in foundation.fragments.iter_mut().enumerate() {
+ if fragment.source_face.is_some() {
+ continue;
+ }
+ // Preserve the input UV words: canonicalizing a welded position can
+ // otherwise change this triangle's per-face UV origin.
+ let vertices = basement_band_uv_vertices(fragment.positions)?;
+ let mut group_ids = [0_usize; 3];
+ let mut uv0_words = [[0_i16; 2]; 3];
+ for (corner, vertex) in vertices.into_iter().enumerate() {
+ uv0_words[corner] = vertex.uv0;
+ let cell = basement_vertex_cell(vertex.position)?;
+ let mut candidates = Vec::new();
+ for dx in -1..=1 {
+ for dy in -1..=1 {
+ let (Some(x), Some(y)) = (cell.0.checked_add(dx), cell.1.checked_add(dy))
+ else {
+ continue;
+ };
+ if let Some(bucket) = buckets.get(&(x, y, vertex.uv0[0], vertex.uv0[1])) {
+ candidates.extend(bucket.iter().copied());
+ }
+ }
+ }
+ candidates.sort_unstable();
+ let group_index = candidates
+ .into_iter()
+ .find(|&index| {
+ let existing = groups[index];
+ existing.uv0 == vertex.uv0
+ && existing.detail_uv == vertex.detail_uv
+ && existing.alpha.to_bits() == vertex.alpha.to_bits()
+ && basement_vertex_xy_distance(existing.position, vertex.position) < 0.01
+ })
+ .unwrap_or_else(|| {
+ let index = groups.len();
+ groups.push(BasementBandVertexGroup {
+ position: vertex.position,
+ uv0: vertex.uv0,
+ detail_uv: vertex.detail_uv,
+ alpha: vertex.alpha,
+ face_normal_sum: [0; 3],
+ });
+ buckets
+ .entry((cell.0, cell.1, vertex.uv0[0], vertex.uv0[1]))
+ .or_default()
+ .push(index);
+ index
+ });
+ fragment.positions[corner] = groups[group_index].position;
+ group_ids[corner] = group_index;
+ }
+ group_ids_by_fragment[fragment_index] = Some(group_ids);
+ uv0_words_by_fragment[fragment_index] = Some(uv0_words);
+ }
+
+ for (fragment_index, fragment) in foundation.fragments.iter().enumerate() {
+ if fragment.source_face.is_some() {
+ continue;
+ }
+ let Some(face_normal) = packed_basement_face_normal(fragment.positions)? else {
+ continue;
+ };
+ let group_ids = group_ids_by_fragment[fragment_index]
+ .ok_or_else(|| "basement band face has no welded vertex groups".to_string())?;
+ // The native normal postpass visits each indexed face once per vertex,
+ // even if malformed geometry gives two corners the same vertex index.
+ for group_index in group_ids.into_iter().collect::<HashSet<_>>() {
+ for axis in 0..3 {
+ groups[group_index].face_normal_sum[axis] += i64::from(face_normal[axis]);
+ }
+ }
+ }
+
+ let normalized = groups
+ .iter()
+ .map(|group| normalized_native_vertex_normal(group.face_normal_sum))
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok((
+ group_ids_by_fragment
+ .into_iter()
+ .map(|group_ids| group_ids.map(|ids| ids.map(|group| normalized[group])))
+ .collect(),
+ uv0_words_by_fragment,
+ ))
+}
+
+fn basement_vertex_cell(position: [f32; 3]) -> Result<(i32, i32), String> {
+ let x = (position[0] / 0.01).floor();
+ let y = (position[1] / 0.01).floor();
+ if !x.is_finite()
+ || !y.is_finite()
+ || x < i32::MIN as f32
+ || x > i32::MAX as f32
+ || y < i32::MIN as f32
+ || y > i32::MAX as f32
+ {
+ return Err("basement band vertex is outside the native weld grid".to_string());
+ }
+ Ok((x as i32, y as i32))
+}
+
+fn basement_vertex_xy_distance(left: [f32; 3], right: [f32; 3]) -> f32 {
+ ((left[0] - right[0]).powi(2) + (left[1] - right[1]).powi(2)).sqrt()
+}
+
+fn basement_band_uv_vertices(positions: [[f32; 3]; 3]) -> Result<[BasementBandVertex; 3], String> {
+ let raw_uv = positions.map(|point| [point[0] * 0.066_f32, point[1] * 0.066_f32]);
+ if !raw_uv.iter().flatten().all(|value| value.is_finite()) {
+ return Err("basement band UV is non-finite".to_string());
+ }
+ let origin = std::array::from_fn::<_, 2, _>(|axis| {
+ raw_uv
+ .iter()
+ .map(|uv| uv[axis])
+ .fold(f32::INFINITY, f32::min)
+ .floor()
+ });
+ let mut result = [BasementBandVertex {
+ position: [0.0; 3],
+ uv0: [0; 2],
+ detail_uv: [0; 2],
+ alpha: 1.0,
+ }; 3];
+ for index in 0..3 {
+ let mut uv0 = [0_i16; 2];
+ for axis in 0..2 {
+ let scaled = ((raw_uv[index][axis] - origin[axis]) * 1024.0_f32).round_ties_even();
+ if !scaled.is_finite() || scaled < i64::MIN as f32 || scaled > i64::MAX as f32 {
+ return Err("basement band UV exceeds the native conversion range".to_string());
+ }
+ // Native FISTP stores the low signed 16-bit word from its integer result.
+ uv0[axis] = (scaled as i64) as i16;
+ }
+ result[index] = BasementBandVertex {
+ position: positions[index],
+ uv0,
+ detail_uv: [0; 2],
+ alpha: 1.0,
+ };
+ }
+ Ok(result)
+}
+
+fn packed_basement_face_normal(positions: [[f32; 3]; 3]) -> Result<Option<[i16; 3]>, String> {
+ let [a, b, c] = positions.map(|point| point.map(f64::from));
+ let first = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
+ let second = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
+ let cross = [
+ first[1] * second[2] - first[2] * second[1],
+ first[2] * second[0] - first[0] * second[2],
+ first[0] * second[1] - first[1] * second[0],
+ ];
+ let length = cross.iter().map(|value| value * value).sum::<f64>().sqrt();
+ if !length.is_finite() {
+ return Err("basement band face normal is non-finite".to_string());
+ }
+ if length == 0.0 {
+ return Ok(None);
+ }
+ Ok(Some(cross.map(|value| {
+ ((value / length * 32767.0).round_ties_even()) as i16
+ })))
+}
+
+fn normalized_native_vertex_normal(face_normal_sum: [i64; 3]) -> Result<[f32; 3], String> {
+ let normal = face_normal_sum.map(|value| value as f64);
+ let length = normal.iter().map(|value| value * value).sum::<f64>().sqrt();
+ if !length.is_finite() {
+ return Err("basement band vertex has a non-finite native face-normal sum".to_string());
+ }
+ if length == 0.0 {
+ return Ok([0.0; 3]);
+ }
+ Ok(normal.map(|value| {
+ let packed = (value / length * 127.0)
+ .round_ties_even()
+ .clamp(-127.0, 127.0) as i8;
+ f32::from(packed) / 128.0
+ }))
+}
+
+fn basement_band_vertices(
+ positions: [[f32; 3]; 3],
+ uv0_words: &[[i16; 2]; 3],
+ normals: &[[f32; 3]; 3],
+) -> [VulkanStaticVertex; 3] {
+ std::array::from_fn(|index| VulkanStaticVertex {
+ position: positions[index],
+ color: [1.0; 3],
+ normal: normals[index],
+ uv: uv0_words[index].map(|value| f32::from(value) / 1024.0),
+ // The native callback leaves the secondary UV words unwritten. The
+ // renderer uses zero here as a deterministic assumption; generated
+ // faces take the base-only path, so the detail coordinate is not sampled.
+ detail_uv: [0.0; 2],
+ overlay_alpha: 1.0,
})
}
@@ -4396,21 +7923,58 @@ fn static_preview_mesh_and_materials(
draw_ranges: Vec::new(),
};
let mut native_handedness_by_range = HashMap::new();
+ let mut type16_present_ranges = HashSet::new();
let mut materials = Vec::new();
let mut material_animations = Vec::new();
- let mut sun_occlusion_range_indices = Vec::new();
- let mut shadow_casters = Vec::new();
- let mut shadow_sphere_baselines = Vec::new();
- let mut shadow_caster_indices_by_object = Vec::with_capacity(roots.len());
+ let mut ambient_effect_slots = Vec::new();
+ let mut control_point_light_slots = Vec::new();
+ let mut effect_documents = HashMap::new();
+ let mut effect_materials = HashMap::new();
let mut component_pose_buffers_by_object = Vec::with_capacity(roots.len());
let mut component_mount_poses_by_object = Vec::with_capacity(roots.len());
- let mut control_animation_instances = Vec::new();
- let mut dynamic_component_geometry = Vec::new();
- let mut dynamic_component_mounts = Vec::new();
- let shadow_receivers = terrain_shadow_receivers(terrain_mesh)?;
+ let mut dynamic_component_geometry_by_object = Vec::with_capacity(roots.len());
+ let mut building_collision_bindings = Vec::new();
+ let mut collision_models =
+ HashMap::<fparkan_assets::AssetId<PreparedModel>, Arc<ModelAsset>>::new();
+ let mut control_animation_instances_by_object = Vec::with_capacity(roots.len());
+ let mut dynamic_shadow_casters = Vec::new();
+ let mut sun_occlusion_range_indices = Vec::new();
+ let mut shadow_casters = Vec::new();
let mut terrain_material_loader = StandaloneWearMaterialLoader::new(root);
- let terrain_component = project_land_msh_to_static_mesh_in_world_space(terrain_mesh)
+ let source_terrain_component = project_land_msh_to_static_mesh_in_world_space(terrain_mesh)
.map_err(|err| format!("project mission terrain for Vulkan: {err}"))?;
+ let footprint_sources = mission_basement_footprints(assets, roots)?;
+ let foundation = if footprint_sources.is_empty() {
+ None
+ } else {
+ let footprints = footprint_sources
+ .iter()
+ .map(|footprint| BasementFootprint {
+ outer: &footprint.outer,
+ inner: &footprint.inner,
+ flatten_inner_to_mean_outer_z: footprint.flatten_inner_to_mean_outer_z,
+ })
+ .collect::<Vec<_>>();
+ Some(
+ build_basement_foundations(terrain_mesh, &footprints)
+ .map_err(|error| format!("prepare mission basement foundations: {error}"))?,
+ )
+ };
+ let effective_roots = foundation
+ .as_ref()
+ .map(|foundation| {
+ apply_basement_placement_deltas(roots, &footprint_sources, &foundation.placements)
+ })
+ .transpose()?;
+ // Every visual, mounted component, effect, and shadow caster below uses this
+ // same root transform, so the native delta is applied once to the hierarchy.
+ let roots = effective_roots.as_deref().unwrap_or(roots);
+ let (terrain_component, shadow_receivers, terrain_query) = prepare_static_terrain_geometry(
+ terrain_mesh,
+ source_terrain_component,
+ terrain,
+ foundation,
+ )?;
let terrain_materials = static_preview_terrain_base_materials(
&mut terrain_material_loader,
root,
@@ -4475,7 +8039,6 @@ fn static_preview_mesh_and_materials(
component_controls.map_or(0, Vec::len)
));
}
- let component_controls = component_controls.map_or(&[][..], Vec::as_slice);
let root_native_kind = component_refs
.first()
.and_then(|(visual, _)| native_shadow_kind(visual.source_type));
@@ -4486,56 +8049,74 @@ fn static_preview_mesh_and_materials(
);
let mut root_shadow_triangles = Vec::new();
let mut root_shadow_spheres = Vec::new();
+ let mut root_shadow_sphere_sources = Vec::new();
+ let mut root_shadow_draw_range_indices = Vec::new();
let mut component_mount_poses = vec![None; component_refs.len()];
let mut component_pose_buffers = vec![None; component_refs.len()];
+ let mut dynamic_component_geometry = vec![None; component_refs.len()];
+ let mut control_animation_instances = vec![None; component_refs.len()];
for (component_index, (visual, model)) in component_refs.iter().enumerate() {
+ let should_render = tree.as_ref().map_or(true, |_| {
+ unit_component_is_visible(component_index, visual.source_type)
+ });
+ let control = component_controls
+ .and_then(|controls| controls.get(component_index))
+ .and_then(Option::as_ref);
let Some(model) = *model else {
+ if legacy_camera.is_none() && root_native_kind == Some(3) && should_render {
+ return Err(format!(
+ "building collision object {object_index} component {component_index} has no model"
+ ));
+ }
+ if component_index == 0 {
+ if let Some(control) = control {
+ append_initial_control_effect_slots(
+ root,
+ mission_root,
+ object_index,
+ component_index,
+ control,
+ None,
+ None,
+ &mut mesh,
+ &mut materials,
+ &mut material_animations,
+ &mut effect_documents,
+ &mut effect_materials,
+ &mut ambient_effect_slots,
+ &mut control_point_light_slots,
+ )?;
+ }
+ }
continue;
};
- let component_control = component_controls
- .get(component_index)
- .and_then(Option::as_ref);
- let control_animates_component = component_control.is_some_and(control_has_tag21);
+ let component_bindings = component_controls
+ .and_then(|controls| controls.get(component_index))
+ .and_then(Option::as_ref)
+ .map_or(&[][..], |control| control.bindings.as_slice());
+ if static_animation_frame.is_none() {
+ if let Some(control) = control {
+ control_animation_instances[component_index] = Some(ControlAnimationInstance {
+ control: control.clone(),
+ model: model.validated.clone(),
+ gate_values: initial_control_gate_values(control),
+ state: ControlInstanceState::default(),
+ last_sampled_node_times: Vec::new(),
+ reported_unsupported_fallback: false,
+ });
+ }
+ }
+ component_pose_buffers[component_index] = static_preview_component_pose_buffer(
+ &model.validated,
+ component_bindings,
+ static_animation_frame,
+ )?;
let timeline_moves_component = static_animation_frame.is_none()
- && (control_animates_component
- || component_chain_has_tag21(
- tree.as_deref(),
- component_controls,
- component_index,
- ));
- let runtime_model = timeline_moves_component.then(|| Arc::new(model.validated.clone()));
- let mut control_state = ControlInstanceState::default();
- component_pose_buffers[component_index] =
- match (static_animation_frame, component_control) {
- (None, Some(control)) => static_preview_control_pose_buffer(
- &model.validated,
- control,
- 0,
- &mut control_state,
- )?,
- (animation_frame, control) => static_preview_component_pose_buffer(
- &model.validated,
- control.map_or(&[][..], |control| control.bindings.as_slice()),
- animation_frame,
- )?,
- };
- if control_animates_component && static_animation_frame.is_none() {
- control_animation_instances.push(ControlAnimationInstance {
- object_index,
+ && control_tree_chain_has_tag21(
component_index,
- model: runtime_model
- .as_ref()
- .map(Arc::clone)
- .ok_or_else(|| "animated component model was not retained".to_string())?,
- control: component_control
- .ok_or_else(|| "tag-21 component control disappeared".to_string())?
- .clone(),
- state: control_state,
- });
- }
- let should_render = tree.as_ref().map_or(true, |_| {
- unit_component_is_visible(component_index, visual.source_type)
- });
+ tree.as_deref(),
+ component_controls.map_or(&[][..], |controls| controls.as_slice()),
+ );
// Native AniMesh only resolves global MSH node sockets for EXTO
// geometry. Internal INTO components use controller/equipment
// slots, whose `parent_or_link` values are not MSH node indices.
@@ -4627,36 +8208,52 @@ fn static_preview_mesh_and_materials(
if let Some(mount_pose) = mount_pose {
component_mount_poses[component_index] = Some(mount_pose);
}
- if timeline_moves_component && mount_pose.is_some() {
- let parent_index = tree
- .as_ref()
- .and_then(|tree| tree[component_index].parent_index)
- .ok_or_else(|| {
- format!("mounted dynamic component {component_index} has no parent")
- })?;
- let socket = usize::try_from(
- records
- .get(component_index)
- .ok_or_else(|| {
- format!("unit component {component_index} is outside decoded records")
- })?
- .parent_or_link,
- )
- .map_err(|_| {
- format!("unit component {component_index} has a negative mount socket")
- })?;
- dynamic_component_mounts.push(DynamicComponentMount {
- object_index,
- component_index,
- parent_index,
- socket,
- });
+ // External tree components have a recovered mount pose; flat
+ // mission components and the root use the mission pose. Hidden
+ // INTO slots use controller links rather than MSH sockets, so
+ // their Control owner transform remains unresolved here.
+ let owner_pose_available =
+ component_index == 0 || tree.is_none() || mount_pose.is_some();
+ if owner_pose_available {
+ if let Some(control) = control {
+ append_initial_control_effect_slots(
+ root,
+ mission_root,
+ object_index,
+ component_index,
+ control,
+ component_pose_buffers[component_index].as_ref(),
+ mount_pose,
+ &mut mesh,
+ &mut materials,
+ &mut material_animations,
+ &mut effect_documents,
+ &mut effect_materials,
+ &mut ambient_effect_slots,
+ &mut control_point_light_slots,
+ )?;
+ }
}
let transform = LegacyIron3dEulerTransform {
translation: mission_root.position,
orientation_radians: mission_root.orientation_raw,
};
+ if legacy_camera.is_none() && root_native_kind == Some(3) && should_render {
+ let shared_model = collision_models
+ .entry(model.id)
+ .or_insert_with(|| Arc::new(model.validated.clone()))
+ .clone();
+ building_collision_bindings.push(building_collision_binding(
+ object_index,
+ component_index,
+ format!("{:?}", model.source),
+ shared_model,
+ transform,
+ mission_root.scale,
+ mount_pose.is_some(),
+ )?);
+ }
if should_render {
let pose_buffer = component_pose_buffers[component_index].as_ref();
let mut component = match (pose_buffer, mount_pose, static_animation_frame) {
@@ -4717,11 +8314,6 @@ fn static_preview_mesh_and_materials(
native_msh_cull_mode(range.batch_flags, component_handedness);
}
let component_shadow_triangles = static_mesh_shadow_triangles(&component);
- let shadow_triangle_range = root_native_kind.map(|_| {
- let start = root_shadow_triangles.len();
- let end = start.saturating_add(component_shadow_triangles.len());
- start..end
- });
if root_native_kind.is_some() {
root_shadow_triangles.extend(component_shadow_triangles);
let node1_pose = mount_pose.and_then(|_| match pose_buffer {
@@ -4739,6 +8331,12 @@ fn static_preview_mesh_and_materials(
node1_pose,
) {
root_shadow_spheres.push(sphere);
+ root_shadow_sphere_sources.push(DynamicShadowSphereSource {
+ component_index,
+ center: model.validated.bounding_sphere_center,
+ radius: model.validated.bounding_sphere_radius,
+ mounted: mount_pose.is_some(),
+ });
}
}
let selector_remap = static_preview_component_materials(
@@ -4750,9 +8348,9 @@ fn static_preview_mesh_and_materials(
&mut material_animations,
)?;
let lightmap_textures = static_preview_lightmap_textures(assets, visual)?;
+ let range_start = mesh.draw_ranges.len();
let vertex_start = mesh.vertices.len();
let vertex_count = component.vertices.len();
- let range_start = mesh.draw_ranges.len();
append_static_preview_component_with_lightmaps(
&mut mesh,
component,
@@ -4763,55 +8361,94 @@ fn static_preview_mesh_and_materials(
)?;
for range_index in range_start..mesh.draw_ranges.len() {
native_handedness_by_range.insert(range_index, component_handedness);
+ if model.validated.type16_present {
+ type16_present_ranges.insert(range_index);
+ }
}
- if root_sun_eligible {
- sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len());
+ if root_native_kind.is_some() {
+ root_shadow_draw_range_indices.extend(range_start..mesh.draw_ranges.len());
}
- if timeline_moves_component && vertex_count != 0 {
- dynamic_component_geometry.push(DynamicComponentGeometry {
- object_index,
- component_index,
- model: runtime_model.as_ref().map(Arc::clone).ok_or_else(|| {
- "animated component model was not retained".to_string()
- })?,
- transform,
- scale: mission_root.scale,
+ if timeline_moves_component {
+ let parent_mount = tree
+ .as_ref()
+ .and_then(|nodes| nodes.get(component_index))
+ .and_then(|node| node.parent_index)
+ .filter(|parent_index| {
+ component_mount_poses[*parent_index].is_some()
+ || component_refs.get(*parent_index).is_some_and(
+ |(parent_visual, _)| {
+ unit_component_is_visible(
+ *parent_index,
+ parent_visual.source_type,
+ )
+ },
+ )
+ })
+ .map(|parent_index| {
+ let socket = usize::try_from(records[component_index].parent_or_link)
+ .expect("mount socket was validated above");
+ (parent_index, socket)
+ });
+ dynamic_component_geometry[component_index] = Some(DynamicComponentGeometry {
+ model: model.validated.clone(),
+ root_transform: transform,
+ owner_scale: mission_root.scale,
+ parent_mount,
vertex_start,
vertex_count,
- shadow_triangle_range,
});
}
+ if root_sun_eligible {
+ sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len());
+ }
mesh_components += 1;
}
}
- component_pose_buffers_by_object.push(component_pose_buffers);
- component_mount_poses_by_object.push(component_mount_poses);
- let shadow_caster_index = if let Some(native_kind) =
+ if let Some(native_kind) =
root_native_kind.filter(|kind| native_shadow_caster_enabled(*kind))
{
let caster_id = u32::try_from(shadow_casters.len())
.map_err(|_| "shadow caster id exceeds u32".to_string())?;
- aggregate_shadow_caster(
+ let root_transform = LegacyIron3dEulerTransform {
+ translation: mission_root.position,
+ orientation_radians: mission_root.orientation_raw,
+ };
+ if let Some(caster) = aggregate_shadow_caster(
caster_id,
native_kind,
- LegacyIron3dEulerTransform {
- translation: mission_root.position,
- orientation_radians: mission_root.orientation_raw,
- },
+ root_transform,
mission_root.scale,
root_shadow_triangles,
&root_shadow_spheres,
- )
- .map(|caster| {
- let index = shadow_casters.len();
- shadow_sphere_baselines.push(caster.sphere);
+ ) {
+ if dynamic_component_geometry.iter().any(Option::is_some) {
+ let component_draw_range_indices = root_shadow_draw_range_indices
+ .into_iter()
+ .filter(|range_index| {
+ mesh.draw_ranges
+ .get(*range_index)
+ .is_some_and(|range| range.index_count != 0)
+ })
+ .collect::<Vec<_>>();
+ if !component_draw_range_indices.is_empty() {
+ dynamic_shadow_casters.push(DynamicShadowCaster {
+ caster_index: shadow_casters.len(),
+ object_index,
+ root_transform,
+ owner_scale: mission_root.scale,
+ initial_sphere: caster.sphere,
+ component_draw_range_indices,
+ component_spheres: root_shadow_sphere_sources.clone(),
+ });
+ }
+ }
shadow_casters.push(caster);
- index
- })
- } else {
- None
- };
- shadow_caster_indices_by_object.push(shadow_caster_index);
+ }
+ }
+ component_pose_buffers_by_object.push(component_pose_buffers);
+ component_mount_poses_by_object.push(component_mount_poses);
+ dynamic_component_geometry_by_object.push(dynamic_component_geometry);
+ control_animation_instances_by_object.push(control_animation_instances);
}
if mesh_components == 0 {
return Err("selected static preview roots have no mesh-backed visual".to_string());
@@ -4837,20 +8474,22 @@ fn static_preview_mesh_and_materials(
Ok(StaticPreviewScene {
mesh,
native_handedness_by_range,
+ type16_present_ranges,
materials,
material_animations,
+ ambient_effect_slots,
+ control_point_light_slots,
+ component_pose_buffers: component_pose_buffers_by_object,
+ component_mount_poses: component_mount_poses_by_object,
+ dynamic_component_geometry: dynamic_component_geometry_by_object,
+ building_collision_bindings,
+ control_animation_instances: control_animation_instances_by_object,
+ dynamic_shadow_casters,
sun_occlusion_range_indices,
shadow_casters,
- shadow_sphere_baselines,
shadow_receivers,
shadow_material_index,
- terrain: Arc::new(terrain.clone()),
- component_pose_buffers: component_pose_buffers_by_object,
- component_mount_poses: component_mount_poses_by_object,
- control_animation_instances,
- dynamic_component_geometry,
- dynamic_component_mounts,
- shadow_caster_indices_by_object,
+ terrain: Arc::new(terrain_query),
camera,
view_camera,
captured_viewport,
@@ -5076,9 +8715,12 @@ fn static_preview_terrain_base_materials(
initial_phase_index: static_material_phase
.map_or(0, usize::from)
.min(base_document.phases.len().saturating_sub(1)),
+ normalized_phase_override: None,
+ ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices,
range_indices: Vec::new(),
+ pipeline_category: base_phases[initial_phase_index].1.pipeline_category,
}],
base: MaterialPhaseBinding {
document: base_document,
@@ -5226,9 +8868,12 @@ fn static_preview_component_materials(
.map_err(|_| "MAT0 phase variant selector exceeds u16".to_string())?;
material_animations.push(MaterialAnimationBinding {
initial_phase_index,
+ normalized_phase_override: None,
+ ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices,
range_indices: Vec::new(),
+ pipeline_category: material.pipeline_category,
}],
base: MaterialPhaseBinding {
document: material.mat0.clone(),
@@ -5343,7 +8988,7 @@ fn append_static_preview_component_with_lightmaps(
// advances to another phase. The range's original selector identifies
// the base binding, while the lightmap selector identifies the secondary
// texture. Keep the generated phase family and the binding/variant it
- // belongs to so the pending MAT0 packet can rebind every frame.
+ // belongs to so update_material_animations can rebind every frame.
let mut lightmap_materials: HashMap<(usize, u8), (Vec<usize>, usize, usize)> = HashMap::new();
for range in component.draw_ranges {
let source_material_index = selector_remap
@@ -5375,6 +9020,7 @@ fn append_static_preview_component_with_lightmaps(
"static preview material selector {source_material_index} has no MAT0 animation binding"
)
})?;
+ let initial_phase_index = bindings[binding_index].initial_phase_index;
let lightmap = lightmap_textures
.get(usize::from(range.lightmap_index))
.expect("lightmap presence checked above");
@@ -5414,6 +9060,16 @@ fn append_static_preview_component_with_lightmaps(
materials.push(lightmap_material);
phase_material_indices.push(usize::from(index));
}
+ let initial_selector = phase_material_indices
+ .get(initial_phase_index)
+ .copied()
+ .ok_or_else(|| "static preview lightmap phase table is empty".to_string())?;
+ let pipeline_category = materials
+ .get(initial_selector)
+ .map(|material| material.pipeline_category)
+ .ok_or_else(|| {
+ "static preview lightmap material selector is unavailable".to_string()
+ })?;
let bindings = material_animations.as_deref_mut().ok_or_else(|| {
"static preview lightmap animation storage is unavailable".to_string()
})?;
@@ -5421,6 +9077,7 @@ fn append_static_preview_component_with_lightmaps(
bindings[binding_index].variants.push(MaterialVariantSet {
phase_material_indices: phase_material_indices.clone(),
range_indices: Vec::new(),
+ pipeline_category,
});
let value = (phase_material_indices, binding_index, variant_index);
lightmap_materials.insert(key, value.clone());
@@ -5527,11 +9184,12 @@ fn run_static_vulkan_mode(
environment: Option<DynamicEnvironment>,
audio: Option<audio::GameAudio>,
target_frames: u64,
- control_clock_target_ms: Option<u32>,
mission: &str,
object_count: usize,
readback_out: Option<&std::path::Path>,
+ readback_sequence_out: Option<&std::path::Path>,
enable_validation: bool,
+ control_clock_target_ms: Option<u32>,
startup_timings: StartupTimings,
) -> Result<String, String> {
let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?;
@@ -5546,7 +9204,7 @@ fn run_static_vulkan_mode(
preview.shadow_material_index,
shadow_placeholder_vertices(),
quad_indices(1)?,
- world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
+ projected_shadow_pipeline_state(),
)?;
let environment_gpu = match environment.as_mut() {
Some(environment) => {
@@ -5557,8 +9215,11 @@ fn run_static_vulkan_mode(
Some(EnvironmentGpuScene::new(
&mut preview.mesh,
&mut preview.native_handedness_by_range,
+ &mut preview.type16_present_ranges,
&mut preview.materials,
&mut preview.material_animations,
+ &mut preview.ambient_effect_slots,
+ &mut preview.dynamic_shadow_casters,
&mut preview.sun_occlusion_range_indices,
&environment_materials,
&material_assets,
@@ -5580,11 +9241,12 @@ fn run_static_vulkan_mode(
shadow_base_index,
audio,
target_frames,
- control_clock_target_ms,
mission,
object_count,
readback_out.map(std::path::Path::to_path_buf),
+ readback_sequence_out.map(std::path::Path::to_path_buf),
enable_validation,
+ control_clock_target_ms,
startup_timings,
);
if let Err(err) = event_loop.run_app(&mut app) {
@@ -5593,96 +9255,23 @@ fn run_static_vulkan_mode(
app.finish()
}
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-enum SceneSampleId {
- Live(u64),
- Replay(u64),
-}
-
-#[derive(Clone, Copy, Debug, PartialEq)]
-struct FrameClockSample {
- id: SceneSampleId,
- time_ms: u32,
- delta_seconds: f32,
-}
-
-#[derive(Clone, Debug, PartialEq)]
-struct SceneFrameUpdate {
- sample: FrameClockSample,
- camera: PreviewCameraFrame,
- viewport: [f32; 2],
- aspect: f32,
- material_update: MaterialFrameUpdate,
- environment_material_update: Option<MaterialFrameUpdate>,
- environment: Option<DynamicEnvironmentFrame>,
-}
-
-#[derive(Debug, Default)]
-struct SceneFrameLifecycle {
- pending: Option<SceneFrameUpdate>,
- frames_presented: u64,
- last_replay_time_ms: u32,
-}
-
-impl SceneFrameLifecycle {
- fn sample_replay(&self, target_ms: u32, target_frames: u64) -> FrameClockSample {
- if let Some(pending) = self.pending.as_ref() {
- return pending.sample;
- }
- let time_ms = control_clock_ms_for_frame(target_ms, self.frames_presented, target_frames);
- FrameClockSample {
- id: SceneSampleId::Replay(self.frames_presented),
- time_ms,
- delta_seconds: replay_environment_delta_seconds(self.last_replay_time_ms, time_ms),
- }
- }
-
- fn prepare(&mut self, update: SceneFrameUpdate) -> bool {
- if self.pending.is_some() {
- return false;
- }
- self.pending = Some(update);
- true
- }
-
- fn record_presented(&mut self) -> Option<u64> {
- let sample = self.pending.take()?.sample;
- let frame_index = self.frames_presented;
- self.frames_presented = self.frames_presented.saturating_add(1);
- if matches!(sample.id, SceneSampleId::Replay(_)) {
- self.last_replay_time_ms = sample.time_ms;
- }
- Some(frame_index)
- }
-}
-
-fn control_clock_ms_for_frame(target_ms: u32, frame_index: u64, target_frames: u64) -> u32 {
- if target_frames <= 1 {
- return target_ms;
- }
- let last_frame = u128::from(target_frames - 1);
- let frame = u128::from(frame_index).min(last_frame);
- ((u128::from(target_ms) * frame) / last_frame) as u32
-}
-
-fn replay_environment_delta_seconds(last_applied_ms: u32, current_ms: u32) -> f32 {
- current_ms.saturating_sub(last_applied_ms) as f32 / 1_000.0
-}
-
struct StaticVulkanApp {
mesh: VulkanStaticMesh,
native_handedness_by_range: HashMap<usize, i8>,
+ type16_present_ranges: HashSet<usize>,
materials: Vec<VulkanStaticMaterial>,
material_animations: Vec<MaterialAnimationBinding>,
- sun_occlusion_range_indices: Vec<usize>,
- terrain: Arc<TerrainWorld>,
+ ambient_effect_slots: Vec<AmbientEffectGpuSlot>,
+ control_point_light_slots: Vec<ControlPointLightSlot>,
+ control_effect_random_states: HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
- control_animation_instances: Vec<ControlAnimationInstance>,
- dynamic_component_geometry: Vec<DynamicComponentGeometry>,
- dynamic_component_mounts: Vec<DynamicComponentMount>,
- shadow_caster_indices_by_object: Vec<Option<usize>>,
- shadow_sphere_baselines: Vec<ShadowSphere>,
+ dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>,
+ building_collision_bindings: Vec<BuildingCollisionBinding>,
+ control_animation_instances: Vec<Vec<Option<ControlAnimationInstance>>>,
+ dynamic_shadow_casters: Vec<DynamicShadowCaster>,
+ sun_occlusion_range_indices: Vec<usize>,
+ terrain: Arc<TerrainWorld>,
world_range_indices: Vec<usize>,
world_range_index_counts: HashMap<usize, u32>,
camera: VulkanStaticCamera,
@@ -5699,11 +9288,16 @@ struct StaticVulkanApp {
shadow_receivers: Vec<ShadowTriangle>,
shadow_cache: ShadowPageCache,
shadow_last_evidence: ShadowFrameEvidence,
- material_time_ms: u32,
+ last_environment_control_clock_ms: u32,
+ control_point_lights_diagnostic_reported: bool,
+ simulation_last_tick: Instant,
+ live_clock_paused_for_zero_extent: bool,
+ simulation_elapsed: Duration,
+ simulation_time_ms: u32,
+ simulation_update_generation: u64,
+ pending_scene_update: Option<SceneFrameUpdate>,
environment_primitive_count: usize,
pressed_keys: HashSet<KeyCode>,
- last_tick: Instant,
- simulation_elapsed: Duration,
mouse_look: bool,
audio: Option<audio::GameAudio>,
camera_mode: &'static str,
@@ -5714,12 +9308,13 @@ struct StaticVulkanApp {
mission: String,
object_count: usize,
readback_out: Option<PathBuf>,
+ readback_sequence_out: Option<PathBuf>,
enable_validation: bool,
startup_timings: StartupTimings,
window_id: Option<WindowId>,
window: Option<Window>,
renderer: Option<VulkanSmokeRenderer>,
- lifecycle: SceneFrameLifecycle,
+ frames_presented: u64,
output: Option<String>,
error: Option<String>,
}
@@ -5734,21 +9329,28 @@ impl StaticVulkanApp {
shadow_base_index: usize,
audio: Option<audio::GameAudio>,
target_frames: u64,
- control_clock_target_ms: Option<u32>,
mission: &str,
object_count: usize,
readback_out: Option<PathBuf>,
+ readback_sequence_out: Option<PathBuf>,
enable_validation: bool,
+ control_clock_target_ms: Option<u32>,
startup_timings: StartupTimings,
) -> Self {
let mut fixed_range_indices = environment_gpu
.as_ref()
.map(EnvironmentGpuScene::fixed_range_indices)
.unwrap_or_default();
+ fixed_range_indices.extend(preview.ambient_effect_slots.iter().flat_map(|slot| {
+ std::iter::once(slot.range.range_index).chain(
+ slot.additional_particle_ranges
+ .iter()
+ .map(|range| range.range_index),
+ )
+ }));
fixed_range_indices.insert(shadow_range.range_index);
- let world_range_indices = (0..preview.mesh.draw_ranges.len())
- .filter(|range_index| !fixed_range_indices.contains(range_index))
- .collect();
+ let world_range_indices =
+ world_draw_range_indices(preview.mesh.draw_ranges.len(), &fixed_range_indices);
let world_range_index_counts = preview
.mesh
.draw_ranges
@@ -5760,17 +9362,20 @@ impl StaticVulkanApp {
Self {
mesh: preview.mesh,
native_handedness_by_range: preview.native_handedness_by_range,
+ type16_present_ranges: preview.type16_present_ranges,
materials: preview.materials,
material_animations: preview.material_animations,
- sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
- terrain: preview.terrain,
+ ambient_effect_slots: preview.ambient_effect_slots,
+ control_point_light_slots: preview.control_point_light_slots,
+ control_effect_random_states: HashMap::new(),
component_pose_buffers: preview.component_pose_buffers,
component_mount_poses: preview.component_mount_poses,
- control_animation_instances: preview.control_animation_instances,
dynamic_component_geometry: preview.dynamic_component_geometry,
- dynamic_component_mounts: preview.dynamic_component_mounts,
- shadow_caster_indices_by_object: preview.shadow_caster_indices_by_object,
- shadow_sphere_baselines: preview.shadow_sphere_baselines,
+ building_collision_bindings: preview.building_collision_bindings,
+ control_animation_instances: preview.control_animation_instances,
+ dynamic_shadow_casters: preview.dynamic_shadow_casters,
+ sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
+ terrain: preview.terrain,
world_range_indices,
world_range_index_counts,
camera: preview.camera,
@@ -5787,11 +9392,16 @@ impl StaticVulkanApp {
shadow_receivers: preview.shadow_receivers,
shadow_cache: ShadowPageCache::default(),
shadow_last_evidence: ShadowFrameEvidence::default(),
- material_time_ms: 0,
+ last_environment_control_clock_ms: 0,
+ control_point_lights_diagnostic_reported: false,
+ simulation_last_tick: Instant::now(),
+ live_clock_paused_for_zero_extent: false,
+ simulation_elapsed: Duration::ZERO,
+ simulation_time_ms: 0,
+ simulation_update_generation: 0,
+ pending_scene_update: None,
environment_primitive_count: 0,
pressed_keys: HashSet::new(),
- last_tick: Instant::now(),
- simulation_elapsed: Duration::ZERO,
mouse_look: false,
audio,
camera_mode: preview.camera_mode,
@@ -5802,12 +9412,13 @@ impl StaticVulkanApp {
mission: mission.to_string(),
object_count,
readback_out,
+ readback_sequence_out,
enable_validation,
startup_timings,
window_id: None,
window: None,
renderer: None,
- lifecycle: SceneFrameLifecycle::default(),
+ frames_presented: 0,
output: None,
error: None,
}
@@ -5816,7 +9427,19 @@ impl StaticVulkanApp {
fn configure_world_draw_ranges(
&self,
renderer: &mut VulkanSmokeRenderer,
+ aspect: f32,
) -> Result<(), String> {
+ let approximate_portals = self
+ .world_range_indices
+ .iter()
+ .filter(|&&index| {
+ let flags = self.mesh.draw_ranges[index].batch_flags;
+ flags & 8 != 0
+ && flags & 0x50 != 0x40
+ && !self.type16_present_ranges.contains(&index)
+ })
+ .count();
+ eprintln!("render_coverage portal_fade_packet_selection_approx={approximate_portals} (first indexed vertex; native CPU clipping selection pending)");
for &range_index in &self.world_range_indices {
let range = self
.mesh
@@ -5827,7 +9450,20 @@ impl StaticVulkanApp {
.materials
.get(usize::from(range.material_index))
.map_or(1.0, |material| material.diffuse_alpha);
- let transparent = native_world_transparent(range, alpha);
+ let alpha_override = native_msh_diffuse_alpha_override(
+ &self.mesh,
+ range_index,
+ self.type16_present_ranges.contains(&range_index),
+ self.view_camera.position,
+ horizontal_fov(&self.view_camera, aspect),
+ )?;
+ let effective_alpha = alpha_override.unwrap_or(alpha);
+ renderer
+ .set_draw_range_diffuse_alpha_override(range_index, alpha_override)
+ .map_err(|error| {
+ format!("configure world draw range {range_index} alpha: {error}")
+ })?;
+ let transparent = native_world_transparent(range, effective_alpha);
renderer
.set_draw_range_transparency(range_index, transparent)
.map_err(|error| format!("configure world draw range {range_index}: {error}"))?;
@@ -5848,7 +9484,7 @@ impl StaticVulkanApp {
&self.mesh,
range,
range.index_count,
- alpha,
+ effective_alpha,
self.view_camera.position,
self.native_handedness_by_range.get(&range_index).copied(),
),
@@ -5860,13 +9496,58 @@ impl StaticVulkanApp {
Ok(())
}
+ fn configure_ambient_effect_draw_ranges(
+ &self,
+ renderer: &mut VulkanSmokeRenderer,
+ ) -> Result<(), String> {
+ for slot in &self.ambient_effect_slots {
+ for (effect_range, _) in ambient_effect_ranges(slot) {
+ let range = self
+ .mesh
+ .draw_ranges
+ .get(effect_range.range_index)
+ .ok_or_else(|| "ambient FX draw range index is out of bounds".to_string())?;
+ let category = self
+ .materials
+ .get(usize::from(range.material_index))
+ .ok_or_else(|| "ambient FX draw range has no MAT0 selector".to_string())?
+ .pipeline_category;
+ renderer
+ .set_draw_range_transparency(effect_range.range_index, true)
+ .map_err(|error| {
+ format!(
+ "configure ambient FX draw range {}: {error}",
+ effect_range.range_index
+ )
+ })?;
+ renderer
+ .set_draw_range_material_category(effect_range.range_index, category)
+ .map_err(|error| format!("configure ambient FX material category: {error}"))?;
+ }
+ }
+ for (range_index, sort_key) in ambient_effect_draw_sort_keys(
+ &self.mesh,
+ &self.ambient_effect_slots,
+ self.view_camera.position,
+ )? {
+ renderer
+ .set_draw_range_sort_key(range_index, sort_key)
+ .map_err(|error| {
+ format!("configure ambient FX draw range {range_index} sort key: {error}")
+ })?;
+ }
+ Ok(())
+ }
+
fn update_world_transparency(
mesh: &VulkanStaticMesh,
materials: &[VulkanStaticMaterial],
world_range_indices: &[usize],
+ type16_present_ranges: &HashSet<usize>,
world_range_index_counts: &HashMap<usize, u32>,
native_handedness_by_range: &HashMap<usize, i8>,
eye_world: [f32; 3],
+ horizontal_fov: f32,
active_materials: &HashMap<usize, ActiveMaterialState>,
renderer: &mut VulkanSmokeRenderer,
) -> Result<(), String> {
@@ -5876,7 +9557,7 @@ impl StaticVulkanApp {
.get(range_index)
.ok_or_else(|| "world draw range index is out of bounds".to_string())?;
let state = active_materials.get(&range_index);
- let alpha = state.map_or_else(
+ let material_alpha = state.map_or_else(
|| {
materials
.get(usize::from(range.material_index))
@@ -5884,6 +9565,17 @@ impl StaticVulkanApp {
},
|state| state.diffuse_alpha,
);
+ let alpha_override = native_msh_diffuse_alpha_override(
+ mesh,
+ range_index,
+ type16_present_ranges.contains(&range_index),
+ eye_world,
+ horizontal_fov,
+ )?;
+ let alpha = alpha_override.unwrap_or(material_alpha);
+ renderer
+ .set_draw_range_diffuse_alpha_override(range_index, alpha_override)
+ .map_err(|error| format!("update world draw range {range_index} alpha: {error}"))?;
renderer
.set_draw_range_transparency(range_index, native_world_transparent(range, alpha))
.map_err(|error| format!("update world draw range {range_index}: {error}"))?;
@@ -5915,188 +9607,107 @@ impl StaticVulkanApp {
}
fn update_free_camera(&mut self, delta_seconds: f32, viewport: [f32; 2]) -> Result<(), String> {
- if self.free_camera.is_none() {
+ let Some(mut free_camera) = self.free_camera else {
return Ok(());
- }
- let aspect = viewport[0] / viewport[1].max(1.0);
- let view_camera = {
- let free_camera = self
- .free_camera
- .as_mut()
- .ok_or_else(|| "free-flight camera disappeared".to_string())?;
- free_camera.advance(&self.pressed_keys, delta_seconds.min(0.1));
- free_camera.keep_above_terrain(&self.terrain)?;
- free_camera.preview_frame(aspect)
};
+ let aspect = viewport[0] / viewport[1].max(1.0);
+ let safe_position = free_camera.position;
+ free_camera.advance(&self.pressed_keys, delta_seconds.clamp(0.0, 0.1));
+ free_camera.keep_above_terrain(&self.terrain)?;
+ if free_camera.position != safe_position
+ && self.building_segment_blocked([safe_position, free_camera.position])?
+ {
+ free_camera.position = safe_position;
+ }
+ let view_camera = free_camera.preview_frame(aspect);
let camera = view_camera.vulkan_camera();
+ self.free_camera = Some(free_camera);
self.view_camera = view_camera;
self.camera = camera;
- let sort_keys = self.world_draw_sort_keys(view_camera.position)?;
- if let Some(renderer) = self.renderer.as_mut() {
- renderer
- .set_camera(camera)
- .map_err(|error| format!("update free-flight camera: {error}"))?;
- for (range_index, sort_key) in sort_keys {
- renderer
- .set_draw_range_sort_key(range_index, sort_key)
- .map_err(|error| {
- format!("update world draw range {range_index} sort key: {error}")
- })?;
- }
- }
Ok(())
}
- fn update_material_clock(&mut self, sample: FrameClockSample) {
- self.material_time_ms = sample.time_ms;
- }
-
- fn update_controlled_component_geometry(
- &mut self,
- sample: FrameClockSample,
- ) -> Result<(), String> {
- let mut pose_changed = false;
- for instance in &mut self.control_animation_instances {
- let pose = static_preview_control_pose_buffer(
- &instance.model,
- &instance.control,
- sample.time_ms,
- &mut instance.state,
- )?;
- let slot = self
- .component_pose_buffers
- .get_mut(instance.object_index)
- .and_then(|buffers| buffers.get_mut(instance.component_index))
- .ok_or_else(|| {
- format!(
- "animated component {} pose slot is outside object {}",
- instance.component_index, instance.object_index
- )
- })?;
- if *slot != pose {
- *slot = pose;
- pose_changed = true;
+ fn building_segment_blocked(&self, world_segment: [[f32; 3]; 2]) -> Result<bool, String> {
+ for binding in &self.building_collision_bindings {
+ if binding.selected_nodes.is_empty() {
+ continue;
}
- }
- if !pose_changed {
- return Ok(());
- }
-
- // Mounts are recalculated from the sampled hierarchy even when the
- // parent has no visible draw geometry of its own.
- let mounts_changed = refresh_dynamic_component_mounts(
- &self.dynamic_component_mounts,
- &self.component_pose_buffers,
- &mut self.component_mount_poses,
- )?;
- let mut geometry_changed = false;
- let mut shadow_updates = Vec::new();
- let mut updated_shadow_casters = HashSet::new();
- for dynamic in &self.dynamic_component_geometry {
- let pose = self
+ let pose_buffer = self
.component_pose_buffers
- .get(dynamic.object_index)
- .and_then(|buffers| buffers.get(dynamic.component_index))
- .and_then(Option::as_ref)
- .ok_or_else(|| {
- format!(
- "animated component {} has no current Node38 hierarchy",
- dynamic.component_index
- )
- })?;
- let mount = self
+ .get(binding.object_index)
+ .and_then(|components| components.get(binding.component_index))
+ .and_then(Option::as_ref);
+ let mount_pose = self
.component_mount_poses
- .get(dynamic.object_index)
- .and_then(|poses| poses.get(dynamic.component_index))
+ .get(binding.object_index)
+ .and_then(|components| components.get(binding.component_index))
.copied()
.flatten();
- let projected = project_dynamic_component_geometry(dynamic, pose, mount)?;
- if projected.vertices.len() != dynamic.vertex_count {
- return Err(format!(
- "animated component {} changed projected vertex count from {} to {}",
- dynamic.component_index,
- dynamic.vertex_count,
- projected.vertices.len()
- ));
- }
- let end = dynamic
- .vertex_start
- .checked_add(dynamic.vertex_count)
- .ok_or_else(|| "animated component vertex range overflowed".to_string())?;
- let destination = self
- .mesh
- .vertices
- .get_mut(dynamic.vertex_start..end)
- .ok_or_else(|| {
+ let nodes = building_collision_nodes(binding, pose_buffer, mount_pose)?;
+ if query_building_segment_world(&binding.model, &nodes, world_segment)
+ .map_err(|error| {
format!(
- "animated component {} vertex range is outside the shared mesh",
- dynamic.component_index
+ "query building collision object {} component {} model {}: {error}",
+ binding.object_index, binding.component_index, binding.model_source
)
- })?;
- if destination != projected.vertices.as_slice() {
- destination.copy_from_slice(&projected.vertices);
- geometry_changed = true;
- }
- if let Some(range) = dynamic.shadow_triangle_range.clone() {
- let triangles = static_mesh_shadow_triangles(&projected);
- if triangles.len() != range.len() {
- return Err(format!(
- "animated component {} changed shadow triangle count from {} to {}",
- dynamic.component_index,
- range.len(),
- triangles.len()
- ));
- }
- shadow_updates.push((dynamic.object_index, range, triangles));
+ })?
+ .is_some()
+ {
+ return Ok(true);
}
}
- for (object_index, range, triangles) in shadow_updates {
- let Some(caster_index) = self
- .shadow_caster_indices_by_object
- .get(object_index)
- .copied()
- .flatten()
- else {
- continue;
+ Ok(false)
+ }
+
+ fn sample_frame_clock(&mut self) -> FrameClockSample {
+ if let Some(target_ms) = self.control_clock_target_ms {
+ let time_ms =
+ control_clock_ms_for_frame(target_ms, self.frames_presented, self.target_frames);
+ self.simulation_elapsed = Duration::from_millis(u64::from(time_ms));
+ self.simulation_time_ms = time_ms;
+ return FrameClockSample {
+ id: SceneSampleId::Replay(self.frames_presented),
+ time_ms,
+ delta_seconds: replay_environment_delta_seconds(
+ self.last_environment_control_clock_ms,
+ time_ms,
+ ),
};
- let caster = self
- .shadow_casters
- .get_mut(caster_index)
- .ok_or_else(|| format!("shadow caster {caster_index} is unavailable"))?;
- let destination = caster.triangles.get_mut(range).ok_or_else(|| {
- format!("animated component shadow range is outside caster {caster_index}")
- })?;
- destination.clone_from_slice(&triangles);
- updated_shadow_casters.insert(caster_index);
}
- for caster_index in updated_shadow_casters {
- let baseline = self
- .shadow_sphere_baselines
- .get(caster_index)
- .copied()
- .ok_or_else(|| format!("shadow sphere baseline {caster_index} is unavailable"))?;
- let caster = self
- .shadow_casters
- .get_mut(caster_index)
- .ok_or_else(|| format!("shadow caster {caster_index} is unavailable"))?;
- // shortcut: native animated sphere updates are unverified; derive
- // a conservative envelope from the native startup sphere and all
- // current triangles, never from a prior frame's expanded bounds.
- caster.sphere = shadow_sphere_enclosing_triangles(baseline, &caster.triangles)?;
- }
- if geometry_changed || mounts_changed {
- let sort_keys = self.world_draw_sort_keys(self.view_camera.position)?;
- if let Some(renderer) = self.renderer.as_mut() {
- for (range_index, sort_key) in sort_keys {
- renderer
- .set_draw_range_sort_key(range_index, sort_key)
- .map_err(|error| {
- format!("update animated component sort key {range_index}: {error}")
- })?;
- }
- }
+ self.sample_live_frame_clock_at(Instant::now())
+ }
+
+ fn pending_or_sample_frame_clock(&mut self) -> FrameClockSample {
+ match self.pending_scene_update.as_ref() {
+ Some(pending) => pending.clock,
+ None => self.sample_frame_clock(),
+ }
+ }
+
+ fn sample_live_frame_clock_at(&mut self, now: Instant) -> FrameClockSample {
+ let elapsed = now.saturating_duration_since(self.simulation_last_tick);
+ self.simulation_last_tick = now;
+ self.simulation_elapsed = self.simulation_elapsed.saturating_add(elapsed);
+ self.simulation_time_ms = self.simulation_elapsed.as_millis() as u32;
+ let sample_id = SceneSampleId::Live(self.simulation_update_generation);
+ self.simulation_update_generation = self.simulation_update_generation.saturating_add(1);
+ FrameClockSample {
+ id: sample_id,
+ time_ms: self.simulation_time_ms,
+ delta_seconds: elapsed.as_secs_f32(),
+ }
+ }
+
+ fn pause_live_clock_at(&mut self, now: Instant) {
+ self.live_clock_paused_for_zero_extent = true;
+ self.simulation_last_tick = now;
+ }
+
+ fn resume_live_clock_at(&mut self, now: Instant) {
+ if self.live_clock_paused_for_zero_extent {
+ self.simulation_last_tick = now;
+ self.live_clock_paused_for_zero_extent = false;
}
- Ok(())
}
fn update_shadow(
@@ -6108,12 +9719,14 @@ impl StaticVulkanApp {
shadow_base_vertex: usize,
shadow_base_index: usize,
shadow_material_index: usize,
+ uploaded_shadow_atlas: &mut VulkanStaticTexture,
renderer: &mut VulkanSmokeRenderer,
+ control_point_lights: &[(VulkanPointLight, u32)],
sky_frame: Option<&SkyFrame<'_>>,
environment_frame: Option<&EnvironmentFrame>,
shadow_camera: Option<ShadowCamera>,
) -> Result<ShadowFrameEvidence, String> {
- let lights = shadow_lights(sky_frame, environment_frame, renderer);
+ let lights = shadow_lights(sky_frame, environment_frame, renderer, control_point_lights);
let visible_casters = native_visible_shadow_casters(
shadow_casters,
renderer.frame_uniforms().clip_from_world,
@@ -6137,9 +9750,12 @@ impl StaticVulkanApp {
&frame,
)?;
let atlas = shadow_atlas_texture_from_frame(&frame);
- renderer
- .update_material_texture(shadow_material_index, 0, &atlas)
- .map_err(|error| format!("upload projected shadow atlas: {error}"))?;
+ if atlas != *uploaded_shadow_atlas {
+ renderer
+ .update_material_texture(shadow_material_index, 0, &atlas)
+ .map_err(|error| format!("upload projected shadow atlas: {error}"))?;
+ *uploaded_shadow_atlas = atlas;
+ }
renderer
.update_mesh_geometry(&mesh.vertices, &mesh.indices, &[geometry])
.map_err(|error| format!("upload environment and shadow geometry: {error}"))?;
@@ -6149,88 +9765,502 @@ impl StaticVulkanApp {
})
}
- fn update_environment(
+ fn prepare_scene_update(
&mut self,
- sample: FrameClockSample,
- viewport: [f32; 2],
- ) -> Result<Option<DynamicEnvironmentFrame>, String> {
- if self.renderer.is_none() {
- return Ok(None);
+ clock: FrameClockSample,
+ drawable_viewport: [f32; 2],
+ ) -> Result<(), String> {
+ let viewport = self.captured_viewport.map_or_else(
+ || [drawable_viewport[0].max(1.0), drawable_viewport[1].max(1.0)],
+ |viewport| [viewport.extent[0] as f32, viewport.extent[1] as f32],
+ );
+ let aspect = viewport[0] / viewport[1].max(1.0);
+ if let Some((pending_id, pending_viewport)) = self
+ .pending_scene_update
+ .as_ref()
+ .map(|pending| (pending.clock.id, pending.viewport))
+ {
+ if pending_id == clock.id {
+ if pending_viewport != viewport {
+ self.refresh_pending_scene_viewport(viewport, aspect)?;
+ }
+ return Ok(());
+ }
}
+ let control_clock_ms = clock.time_ms;
+ let effect_elapsed_seconds = control_clock_ms as f32 / 1000.0;
+ // CTLD's validated NDP defaults seed the native query gate. This
+ // preview does not process mission Action1 updates, so it retains that
+ // initial provider value for the capture.
+ update_control_animation_pose_buffers(
+ &mut self.control_animation_instances,
+ &mut self.component_pose_buffers,
+ control_clock_ms,
+ )?;
+ update_dynamic_component_geometry(
+ &self.dynamic_component_geometry,
+ &self.component_pose_buffers,
+ &mut self.component_mount_poses,
+ &mut self.mesh,
+ )?;
+ self.update_free_camera(clock.delta_seconds, viewport)?;
+ refresh_dynamic_shadow_casters(
+ &mut self.shadow_casters,
+ &self.dynamic_shadow_casters,
+ &self.mesh,
+ &self.component_pose_buffers,
+ &self.component_mount_poses,
+ )?;
+ for slot in &mut self.ambient_effect_slots {
+ if slot.owner_component_is_mounted {
+ slot.mount_pose = self
+ .component_mount_poses
+ .get(slot.owner_object_index)
+ .and_then(|components| components.get(slot.owner_component_index))
+ .copied()
+ .flatten();
+ }
+ }
+ let control_effect_random_samples = advance_control_effect_random_states(
+ &mut self.control_effect_random_states,
+ &self.ambient_effect_slots,
+ &self.control_point_light_slots,
+ clock.id,
+ control_clock_ms,
+ effect_elapsed_seconds,
+ );
+ let control_point_lights = update_control_point_lights(
+ &mut self.control_point_light_slots,
+ &self.component_pose_buffers,
+ &self.component_mount_poses,
+ effect_elapsed_seconds,
+ &control_effect_random_samples,
+ std::env::var_os("FPARKAN_CONTROL_POINT_LIGHT_DIAGNOSTIC").is_some()
+ && !self.control_point_lights_diagnostic_reported
+ && self
+ .control_clock_target_ms
+ .is_none_or(|target_ms| control_clock_ms >= target_ms),
+ );
+ let control_vulkan_lights = ordinary_control_point_lights(&control_point_lights);
+ let effect_updates = update_ambient_effect_geometry(
+ &mut self.ambient_effect_slots,
+ &self.component_pose_buffers,
+ &mut self.mesh,
+ &mut self.material_animations,
+ effect_elapsed_seconds,
+ &control_effect_random_samples,
+ self.view_camera.position,
+ )?;
+ let world_sort_keys = self.world_draw_sort_keys(self.view_camera.position)?;
+ let ambient_sort_keys = ambient_effect_draw_sort_keys(
+ &self.mesh,
+ &self.ambient_effect_slots,
+ self.view_camera.position,
+ )?;
let camera = self.view_camera;
+ let material_update = sample_material_animations(
+ &mut self.material_animations,
+ &self.materials,
+ clock.time_ms,
+ )?;
+ let active_materials = &material_update.active_materials;
+ apply_ambient_effect_material_tints(
+ &self.ambient_effect_slots,
+ &mut self.mesh,
+ &effect_updates,
+ &material_update.active_materials,
+ )?;
+ let diagnostic_mode = std::env::var("FPARKAN_AMBIENT_FX_DIAGNOSTIC").ok();
+ if diagnostic_mode.is_some() {
+ let generator_capture_is_final = diagnostic_mode.as_deref() == Some("generator")
+ && self
+ .control_clock_target_ms
+ .is_none_or(|target_ms| control_clock_ms >= target_ms);
+ for update in &effect_updates {
+ let Some((slot, range)) = self.ambient_effect_slots.iter().find_map(|slot| {
+ ambient_effect_range_for(slot, update.range_index).map(|range| (slot, range))
+ }) else {
+ continue;
+ };
+ if generator_capture_is_final
+ && slot.kind == AmbientEffectKind::GenerBall
+ && slot.control_source == "fortif.rlb/fr_l_gener.ctl"
+ {
+ let random_sample = control_effect_random_samples
+ .get(&slot.random_instance_key)
+ .copied()
+ .unwrap_or_default();
+ let phase = ambient_effect_phase(
+ slot.time_mode,
+ slot.header_flags,
+ effect_elapsed_seconds,
+ slot.duration_seconds,
+ )
+ .map(|phase| {
+ native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)
+ });
+ let evaluated = phase.and_then(|phase| {
+ evaluate_f_gener_ball(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ phase,
+ effect_elapsed_seconds,
+ self.view_camera.position,
+ )
+ });
+ let expected_vertices = evaluated.as_ref().map(|frame| frame.vertices);
+ let expected_center =
+ expected_vertices.map(|vertices| ambient_effect_quad_center(&vertices));
+ let expected_size =
+ expected_vertices.map(|vertices| ambient_effect_quad_size(&vertices));
+ let expected_screen = expected_vertices.and_then(|vertices| {
+ ambient_effect_projected_bounds(
+ &vertices,
+ self.view_camera.clip_from_world,
+ self.captured_viewport
+ .map_or([1280, 1024], |viewport| viewport.extent),
+ )
+ });
+ let actual_vertices = self
+ .mesh
+ .vertices
+ .get(range.vertex_start..range.vertex_start.saturating_add(4));
+ let actual_center: Option<[f32; 3]> = actual_vertices.map(|vertices| {
+ std::array::from_fn(|axis| {
+ vertices
+ .iter()
+ .map(|vertex| vertex.position[axis])
+ .sum::<f32>()
+ / 4.0
+ })
+ });
+ let actual_rgb = actual_vertices
+ .and_then(|vertices| vertices.first())
+ .map(|vertex| vertex.color);
+ let actual_alpha = actual_vertices
+ .and_then(|vertices| vertices.first())
+ .map(|vertex| vertex.overlay_alpha);
+ let material = active_materials.get(&update.range_index);
+ eprintln!(
+ "ambient_fx_generator_diagnostic elapsed={effect_elapsed_seconds:.3} clock_ms={control_clock_ms} owner_object={} owner_component={} source={} table={} row={} points={:?} opcode={:?} command={} range={} submitted_indices={} effect_phase={phase:?} evaluator_active={:?} fx_mat_phase={:?} fx_alpha={:?} expected_center={expected_center:?} expected_size={expected_size:?} expected_screen={expected_screen:?} actual_center={actual_center:?} actual_rgb={actual_rgb:?} actual_alpha={actual_alpha:?} mat_selector={:?} mat_phase_index={:?} sampled_mat_rgb={:?}",
+ slot.owner_object_index,
+ slot.owner_component_index,
+ slot.control_source,
+ slot.control_table_index,
+ slot.control_row_index,
+ slot.control_point_indices,
+ slot.command.opcode,
+ slot.command_index,
+ update.range_index,
+ update.index_count,
+ evaluated.as_ref().map(|frame| frame.active),
+ evaluated.as_ref().map(|frame| frame.material_phase),
+ evaluated.as_ref().map(|frame| frame.color[3]),
+ material.map(|material| material.material_index),
+ material.map(|material| material.phase_index),
+ material.map(|material| material.additive_rgb),
+ );
+ continue;
+ }
+ if diagnostic_mode.as_deref() == Some("generator")
+ || slot.control_source != "animals.rlb/a_a_l3.ctl"
+ || update.index_count == 0
+ {
+ continue;
+ }
+ let start = range.vertex_start;
+ let vertices = self
+ .mesh
+ .vertices
+ .get(start..start.saturating_add(range.vertex_capacity))
+ .unwrap_or_default();
+ let center = if vertices.is_empty() {
+ [0.0; 3]
+ } else {
+ std::array::from_fn(|axis| {
+ vertices
+ .iter()
+ .map(|vertex| vertex.position[axis])
+ .sum::<f32>()
+ / vertices.len() as f32
+ })
+ };
+ let color = vertices
+ .first()
+ .map(|vertex| vertex.color)
+ .unwrap_or([0.0; 3]);
+ let alpha = vertices
+ .first()
+ .map(|vertex| vertex.overlay_alpha)
+ .unwrap_or(0.0);
+ eprintln!(
+ "ambient_fx_diagnostic elapsed={effect_elapsed_seconds:.3} source={} table={} row={} points={:?} owners={:?} parent_translation={:?} opcode={:?} command={} range={} active_indices={} center={center:?} command_rgb={:?} sampled_mat_rgb={color:?} alpha={alpha:.3}",
+ slot.control_source,
+ slot.control_table_index,
+ slot.control_row_index,
+ slot.control_point_indices,
+ slot.control_point_owners,
+ [slot.world_from_effect[0][3], slot.world_from_effect[1][3], slot.world_from_effect[2][3]],
+ slot.command.opcode,
+ slot.command_index,
+ update.range_index,
+ update.index_count,
+ update.command_rgb,
+ );
+ }
+ }
+
if let Some(audio) = self.audio.as_mut() {
audio
.update_listener(camera.position, camera.forward, camera.up)
.map_err(|error| format!("update audio listener: {error}"))?;
}
- let environment_frame = if self.environment.is_some() {
- let renderer = self
- .renderer
- .as_mut()
- .ok_or_else(|| "native Vulkan renderer was not initialized".to_string())?;
- let environment = self
- .environment
- .as_mut()
- .ok_or_else(|| "dynamic environment disappeared".to_string())?;
- Some(environment.update(
- sample.delta_seconds,
+ let environment = if let Some(environment) = self.environment.as_mut() {
+ Some(environment.update_cpu(
+ clock.delta_seconds,
&camera,
- viewport[0] / viewport[1].max(1.0),
+ aspect,
viewport,
- renderer,
self.audio.as_mut(),
)?)
} else {
None
};
- if let Some(frame) = environment_frame.as_ref() {
- self.environment_primitive_count = frame.environment_frame.primitives.len();
+ self.environment_primitive_count = environment
+ .as_ref()
+ .map_or(0, |frame| frame.environment_frame.primitives.len());
+ if matches!(clock.id, SceneSampleId::Replay(_)) {
+ self.last_environment_control_clock_ms = control_clock_ms;
}
- Ok(environment_frame)
+ let environment_material_update =
+ if let Some(environment_gpu) = self.environment_gpu.as_mut() {
+ Some(sample_material_animations(
+ &mut environment_gpu.material_animations,
+ &self.materials,
+ clock.time_ms,
+ )?)
+ } else {
+ None
+ };
+ let environment_point_light_count = environment.as_ref().map_or(0, |frame| {
+ frame
+ .environment_frame
+ .point_lights
+ .iter()
+ .filter(|light| light.active)
+ .count()
+ });
+ report_control_point_lights_once(
+ &mut self.control_point_lights_diagnostic_reported,
+ control_clock_ms,
+ self.control_clock_target_ms,
+ environment_point_light_count,
+ &control_point_lights,
+ &control_vulkan_lights,
+ );
+ self.pending_scene_update = Some(SceneFrameUpdate {
+ clock,
+ camera,
+ viewport,
+ aspect,
+ control_point_lights,
+ control_vulkan_lights,
+ effect_updates,
+ world_sort_keys,
+ ambient_sort_keys,
+ material_update,
+ environment_material_update,
+ environment,
+ });
+ Ok(())
}
- fn apply_environment_view(
+ fn refresh_pending_scene_viewport(
&mut self,
- frame: Option<&DynamicEnvironmentFrame>,
- environment_material_update: Option<&MaterialFrameUpdate>,
- camera: PreviewCameraFrame,
viewport: [f32; 2],
+ aspect: f32,
) -> Result<(), String> {
+ let Some((previous_camera, previous_aspect)) = self
+ .pending_scene_update
+ .as_ref()
+ .map(|pending| (pending.camera, pending.aspect))
+ else {
+ return Ok(());
+ };
+ let camera = previous_camera.with_aspect(previous_aspect, aspect);
+ self.view_camera = camera;
+ self.camera = camera.vulkan_camera();
+ let environment_camera = environment_camera_for_preview(&camera, aspect, viewport);
+ if let Some(environment) = self
+ .pending_scene_update
+ .as_mut()
+ .and_then(|pending| pending.environment.as_mut())
+ {
+ environment
+ .environment_frame
+ .reproject_for_camera(environment_camera);
+ }
+ let pending = self
+ .pending_scene_update
+ .as_mut()
+ .expect("pending scene update was checked above");
+ pending.camera = camera;
+ pending.viewport = viewport;
+ pending.aspect = aspect;
+ Ok(())
+ }
+
+ fn render_scene_update(&mut self) -> Result<(), String> {
+ let Some(frame) = self.pending_scene_update.as_ref() else {
+ return Ok(());
+ };
let Some(renderer) = self.renderer.as_mut() else {
return Ok(());
};
- let aspect = viewport[0] / viewport[1].max(1.0);
- if let Some(frame) = frame {
+ renderer
+ .set_camera(frame.camera.vulkan_camera())
+ .map_err(|error| format!("apply sampled scene camera: {error}"))?;
+ apply_material_frame_update(
+ &frame.material_update,
+ &mut self.material_animations,
+ renderer,
+ )?;
+ for update in &frame.effect_updates {
+ renderer
+ .set_draw_range_index_count(update.range_index, update.index_count)
+ .map_err(|error| {
+ format!(
+ "update ambient FX draw range {}: {error}",
+ update.range_index
+ )
+ })?;
+ }
+ for &(range_index, sort_key) in &frame.world_sort_keys {
+ renderer
+ .set_draw_range_sort_key(range_index, sort_key)
+ .map_err(|error| {
+ format!("update dynamic world draw range {range_index} sort key: {error}")
+ })?;
+ }
+ for &(range_index, sort_key) in &frame.ambient_sort_keys {
+ renderer
+ .set_draw_range_sort_key(range_index, sort_key)
+ .map_err(|error| {
+ format!("update ambient FX draw range {range_index} sort key: {error}")
+ })?;
+ }
+ Self::update_world_transparency(
+ &self.mesh,
+ &self.materials,
+ &self.world_range_indices,
+ &self.type16_present_ranges,
+ &self.world_range_index_counts,
+ &self.native_handedness_by_range,
+ frame.camera.position,
+ horizontal_fov(&frame.camera, frame.aspect),
+ &frame.material_update.active_materials,
+ renderer,
+ )?;
+ let shadow_camera = ShadowCamera {
+ position: frame.camera.position,
+ viewport_width: frame.viewport[0],
+ horizontal_fov: horizontal_fov(&frame.camera, frame.aspect),
+ };
+ if let Some(sampled_environment) = frame.environment.as_ref() {
let environment = self
.environment
.as_ref()
- .ok_or_else(|| "dynamic environment disappeared".to_string())?;
+ .ok_or_else(|| "sampled atmosphere lost its owner".to_string())?;
let sky_frame = SkyFrame::from_atmosphere(
- &frame.atmosphere,
+ &sampled_environment.atmosphere,
&environment.materials,
- frame.absolute_time_seconds,
+ sampled_environment.absolute_time_seconds,
);
+ let environment_frame = &sampled_environment.environment_frame;
+ let mut uniforms = renderer.frame_uniforms();
+ uniforms.clip_from_world = frame.camera.clip_from_world;
+ for (target, source) in uniforms
+ .directional_lights
+ .iter_mut()
+ .zip(sky_frame.directional_lights)
+ {
+ target.direction = source.direction;
+ target.rgb = source.color;
+ target.coefficients = [0.0, 0.0, 1.0];
+ target.active = source.active;
+ }
+ uniforms.lighting_floor = sky_frame.lighting_floor;
+ let fog_rgb = sky_frame
+ .screen_gradient(frame.camera.native_sky_heading(), [0.0; 3])
+ .color
+ .rgba();
+ uniforms.fog_color = [fog_rgb[0], fog_rgb[1], fog_rgb[2]];
+ uniforms.fog_start = sky_frame.fog.start.max(0.0);
+ uniforms.fog_end = sky_frame.fog.end.max(uniforms.fog_start);
+ uniforms.camera_position = frame.camera.position;
+ renderer
+ .set_frame_uniforms(uniforms)
+ .map_err(|error| format!("update sky frame uniforms: {error}"))?;
+ let mut uniforms = renderer.frame_uniforms();
+ uniforms.point_light = environment_frame
+ .point_lights
+ .iter()
+ .find(|light| light.active)
+ .map(|light| VulkanPointLight {
+ position: light.position,
+ rgb: [light.color[0], light.color[1], light.color[2]],
+ range: light.range,
+ coefficients: light.attenuation,
+ active: true,
+ })
+ .unwrap_or_default();
+ renderer
+ .set_frame_uniforms(uniforms)
+ .map_err(|error| format!("update environment point-light uniforms: {error}"))?;
+ let mut point_lights = environment_frame
+ .point_lights
+ .iter()
+ .filter(|light| light.active)
+ .map(|light| VulkanPointLight {
+ position: light.position,
+ rgb: [light.color[0], light.color[1], light.color[2]],
+ range: light.range,
+ coefficients: light.attenuation,
+ active: true,
+ })
+ .collect::<Vec<_>>();
+ point_lights.extend(frame.control_vulkan_lights.iter().copied());
+ renderer
+ .set_point_lights(&point_lights)
+ .map_err(|error| format!("update complete scene point-light set: {error}"))?;
if let Some(environment_gpu) = self.environment_gpu.as_mut() {
- let material_update = environment_material_update
+ let material_update = frame
+ .environment_material_update
+ .as_ref()
.ok_or_else(|| "sampled environment material state is missing".to_string())?;
+ apply_material_frame_update(
+ material_update,
+ &mut environment_gpu.material_animations,
+ renderer,
+ )?;
environment_gpu.update(
&mut self.mesh,
renderer,
material_update,
&sky_frame,
- &frame.sky_mesh,
- &frame.environment_frame,
- &camera,
- viewport,
+ &sampled_environment.sky_mesh,
+ environment_frame,
+ &frame.camera,
+ frame.viewport,
&self.terrain,
&self.sun_occlusion_range_indices,
)?;
}
- let shadow_camera = ShadowCamera {
- position: camera.position,
- viewport_width: viewport[0],
- horizontal_fov: horizontal_fov(&camera, aspect),
- };
+ let shadow_material = self
+ .materials
+ .get_mut(self.shadow_material_index)
+ .ok_or_else(|| "projected shadow material is out of bounds".to_string())?;
self.shadow_last_evidence = Self::update_shadow(
&mut self.mesh,
&mut self.shadow_cache,
@@ -6240,17 +10270,21 @@ impl StaticVulkanApp {
self.shadow_base_vertex,
self.shadow_base_index,
self.shadow_material_index,
+ &mut shadow_material.texture,
renderer,
+ &frame.control_point_lights,
Some(&sky_frame),
- Some(&frame.environment_frame),
+ Some(environment_frame),
Some(shadow_camera),
)?;
} else {
- let shadow_camera = Some(ShadowCamera {
- position: camera.position,
- viewport_width: viewport[0],
- horizontal_fov: horizontal_fov(&camera, aspect),
- });
+ renderer
+ .set_point_lights(&frame.control_vulkan_lights)
+ .map_err(|error| format!("update complete scene point-light set: {error}"))?;
+ let shadow_material = self
+ .materials
+ .get_mut(self.shadow_material_index)
+ .ok_or_else(|| "projected shadow material is out of bounds".to_string())?;
self.shadow_last_evidence = Self::update_shadow(
&mut self.mesh,
&mut self.shadow_cache,
@@ -6260,148 +10294,22 @@ impl StaticVulkanApp {
self.shadow_base_vertex,
self.shadow_base_index,
self.shadow_material_index,
+ &mut shadow_material.texture,
renderer,
+ &frame.control_point_lights,
None,
None,
- shadow_camera,
+ Some(shadow_camera),
)?;
}
Ok(())
}
- fn refresh_pending_viewport(&mut self, viewport: [f32; 2]) -> Result<(), String> {
- if self.captured_viewport.is_some() {
- return Ok(());
- }
- let Some(mut update) = self.lifecycle.pending.clone() else {
- return Ok(());
- };
- let viewport = [viewport[0].max(1.0), viewport[1].max(1.0)];
- let aspect = viewport[0] / viewport[1];
- let camera = update.camera.with_aspect(update.aspect, aspect);
- self.view_camera = camera;
- self.camera = camera.vulkan_camera();
- if let Some(renderer) = self.renderer.as_mut() {
- renderer
- .set_camera(self.camera)
- .map_err(|error| format!("refresh free-flight camera viewport: {error}"))?;
- }
- let environment_camera = environment_camera_for_preview(&camera, aspect, viewport);
- if let Some(environment) = update.environment.as_mut() {
- environment
- .environment_frame
- .reproject_for_camera(environment_camera);
- }
- update.camera = camera;
- update.viewport = viewport;
- update.aspect = aspect;
- self.lifecycle.pending = Some(update);
- Ok(())
- }
-
- fn apply_pending_scene_update(&mut self) -> Result<(), String> {
- let Some(frame) = self.lifecycle.pending.take() else {
- return Ok(());
- };
- let result = (|| {
- {
- let Some(renderer) = self.renderer.as_mut() else {
- return Err("native Vulkan renderer was not initialized".to_string());
- };
- apply_material_frame_update(
- &frame.material_update,
- &mut self.material_animations,
- renderer,
- )?;
- Self::update_world_transparency(
- &self.mesh,
- &self.materials,
- &self.world_range_indices,
- &self.world_range_index_counts,
- &self.native_handedness_by_range,
- frame.camera.position,
- &frame.material_update.active_materials,
- renderer,
- )?;
- if let Some(material_update) = frame.environment_material_update.as_ref() {
- let environment_gpu = self.environment_gpu.as_mut().ok_or_else(|| {
- "sampled environment material state lost its owner".to_string()
- })?;
- apply_material_frame_update(
- material_update,
- &mut environment_gpu.material_animations,
- renderer,
- )?;
- }
- }
- self.apply_environment_view(
- frame.environment.as_ref(),
- frame.environment_material_update.as_ref(),
- frame.camera,
- frame.viewport,
- )
- })();
- self.lifecycle.pending = Some(frame);
- result
- }
-
- fn sample_frame_clock(&mut self) -> FrameClockSample {
- if let Some(target_ms) = self.control_clock_target_ms {
- return self.lifecycle.sample_replay(target_ms, self.target_frames);
- }
- self.sample_live_frame_clock_at(Instant::now())
- }
-
- fn sample_live_frame_clock_at(&mut self, now: Instant) -> FrameClockSample {
- if let Some(pending) = self.lifecycle.pending.as_ref() {
- return pending.sample;
- }
- let elapsed = now.saturating_duration_since(self.last_tick);
- self.last_tick = now;
- self.simulation_elapsed = self.simulation_elapsed.saturating_add(elapsed);
- FrameClockSample {
- id: SceneSampleId::Live(self.lifecycle.frames_presented),
- time_ms: self.simulation_elapsed.as_millis() as u32,
- delta_seconds: elapsed.as_secs_f32(),
- }
- }
-
- fn prepare_scene_update(
- &mut self,
- sample: FrameClockSample,
- viewport: [f32; 2],
- ) -> Result<SceneFrameUpdate, String> {
- let viewport = [viewport[0].max(1.0), viewport[1].max(1.0)];
- let aspect = viewport[0] / viewport[1];
- self.update_free_camera(sample.delta_seconds, viewport)?;
- self.update_material_clock(sample);
- self.update_controlled_component_geometry(sample)?;
- let material_update = sample_material_animations(
- &mut self.material_animations,
- &self.materials,
- self.material_time_ms,
- )?;
- let environment_material_update = self
- .environment_gpu
- .as_mut()
- .map(|environment_gpu| {
- sample_material_animations(
- &mut environment_gpu.material_animations,
- &self.materials,
- self.material_time_ms,
- )
- })
- .transpose()?;
- let environment = self.update_environment(sample, viewport)?;
- Ok(SceneFrameUpdate {
- sample,
- camera: self.view_camera,
- viewport,
- aspect,
- material_update,
- environment_material_update,
- environment,
- })
+ fn record_presented_scene_frame(&mut self) -> u64 {
+ let frame_index = self.frames_presented;
+ self.frames_presented = self.frames_presented.saturating_add(1);
+ self.pending_scene_update = None;
+ frame_index
}
fn set_mouse_look(&mut self, active: bool) {
@@ -6492,6 +10400,13 @@ impl StaticVulkanApp {
.map_or(report.renderer_report.swapchain_image_format, |artifact| {
artifact.format
});
+ if let Some(control_clock_ms) = self.control_clock_target_ms {
+ emit_control_animation_capture_diagnostics(
+ &self.control_animation_instances,
+ &self.component_pose_buffers,
+ control_clock_ms,
+ );
+ }
let captured_crop = self.captured_viewport.map_or_else(
|| "none".to_string(),
|viewport| {
@@ -6502,14 +10417,17 @@ impl StaticVulkanApp {
},
);
self.output = Some(format!(
- "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, captured_viewport_crop={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}",
+ "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, captured_viewport_crop={}, control_clock_ms={}, control_clock_mode={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, readback_sequence={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}",
self.mission,
- self.lifecycle.frames_presented,
+ self.frames_presented,
self.object_count,
self.mesh_components,
self.terrain_components,
self.camera_mode,
captured_crop,
+ self.control_clock_target_ms
+ .map_or_else(|| "realtime".to_string(), |clock| clock.to_string()),
+ control_clock_mode_label(self.control_clock_target_ms),
self.materials.len(),
self.environment_primitive_count,
self.shadow_last_evidence.visible_casters,
@@ -6519,6 +10437,9 @@ impl StaticVulkanApp {
report.renderer_report.swapchain_image_format,
readback_format,
readback_path.as_deref().unwrap_or("none"),
+ self.readback_sequence_out
+ .as_deref()
+ .map_or_else(|| "none".to_string(), |path| path.display().to_string()),
elapsed_ms(self.startup_timings.mission_assets),
elapsed_ms(self.startup_timings.terrain_materials),
elapsed_ms(self.startup_timings.sky),
@@ -6635,7 +10556,15 @@ impl ApplicationHandler for StaticVulkanApp {
event_loop.exit();
return;
}
- if let Err(error) = self.configure_world_draw_ranges(&mut renderer) {
+ if let Err(error) = self.configure_world_draw_ranges(
+ &mut renderer,
+ size.width.max(1) as f32 / size.height.max(1) as f32,
+ ) {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
+ }
+ if let Err(error) = self.configure_ambient_effect_draw_ranges(&mut renderer) {
self.error = Some(error);
event_loop.exit();
return;
@@ -6659,13 +10588,28 @@ impl ApplicationHandler for StaticVulkanApp {
}
}
self.startup_timings.gpu_initialization = gpu_initialization_started.elapsed();
- renderer.set_readback_enabled(self.readback_out.is_some());
+ renderer.set_readback_enabled(
+ self.readback_out.is_some() || self.readback_sequence_out.is_some(),
+ );
self.window_id = Some(window.id());
self.window = Some(window);
self.renderer = Some(renderer);
- self.last_tick = Instant::now();
- self.simulation_elapsed = Duration::ZERO;
- self.material_time_ms = 0;
+ self.simulation_last_tick = Instant::now();
+ self.live_clock_paused_for_zero_extent = false;
+ self.pending_scene_update = None;
+ let drawable_size = self
+ .window
+ .as_ref()
+ .map(Window::inner_size)
+ .unwrap_or_default();
+ if let Err(error) = self.update_free_camera(
+ 0.0,
+ [drawable_size.width as f32, drawable_size.height as f32],
+ ) {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
+ }
self.schedule_next_redraw();
}
@@ -6736,25 +10680,27 @@ impl ApplicationHandler for StaticVulkanApp {
}
if drawable_size.width == 0 || drawable_size.height == 0 {
event_loop.set_control_flow(ControlFlow::Wait);
+ self.pause_live_clock_at(Instant::now());
} else {
event_loop.set_control_flow(ControlFlow::Poll);
+ self.resume_live_clock_at(Instant::now());
+ // The pending simulation sample is reused; its view and
+ // viewport-dependent geometry refresh before the retry.
}
}
WindowEvent::RedrawRequested => {
- if self.lifecycle.pending.is_none() {
+ if self.pending_scene_update.is_none() {
return;
}
- let drawable_available = self.window.as_ref().is_some_and(|window| {
- let size = window.inner_size();
- size.width != 0 && size.height != 0
- });
- if drawable_available {
- if let Err(error) = self.apply_pending_scene_update() {
- self.error = Some(error);
- event_loop.exit();
- return;
- }
+ if let Err(error) = self.render_scene_update() {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
}
+ let diagnostic_clock_ms = self
+ .pending_scene_update
+ .as_ref()
+ .map_or(self.simulation_time_ms, |frame| frame.clock.time_ms);
let outcome = {
let Some(renderer) = self.renderer.as_mut() else {
self.error = Some("native Vulkan renderer was not initialized".to_string());
@@ -6765,28 +10711,50 @@ impl ApplicationHandler for StaticVulkanApp {
};
match outcome {
Ok(VulkanSmokeFrameOutcome::Presented) => {
- self.lifecycle.record_presented();
- }
- Ok(VulkanSmokeFrameOutcome::Recreated) => {
- if let Some((width, height)) = self
- .renderer
- .as_ref()
- .map(|renderer| renderer.report().swapchain_extent)
- {
- if width != 0 && height != 0 {
- if let Err(error) =
- self.refresh_pending_viewport([width as f32, height as f32])
- {
- self.error = Some(error);
+ let frame_index = self.record_presented_scene_frame();
+ if let Some(directory) = self.readback_sequence_out.as_deref() {
+ let artifact = match self
+ .renderer
+ .as_mut()
+ .expect("renderer presented a frame")
+ .readback_last_submitted_frame()
+ {
+ Ok(Some(artifact)) => artifact,
+ Ok(None) => {
+ self.error = Some(
+ "native Vulkan renderer produced no synchronized sequence readback"
+ .to_string(),
+ );
+ event_loop.exit();
+ return;
+ }
+ Err(error) => {
+ self.error =
+ Some(format!("read back frame {frame_index}: {error}"));
event_loop.exit();
return;
}
+ };
+ if let Err(error) = write_readback_sequence_frame(
+ directory,
+ frame_index,
+ diagnostic_clock_ms,
+ &artifact,
+ ) {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
}
}
}
+ Ok(VulkanSmokeFrameOutcome::Recreated) => {}
Ok(VulkanSmokeFrameOutcome::ZeroExtent) => {
- if !drawable_available {
+ if self.window.as_ref().is_some_and(|window| {
+ let size = window.inner_size();
+ size.width == 0 || size.height == 0
+ }) {
event_loop.set_control_flow(ControlFlow::Wait);
+ self.pause_live_clock_at(Instant::now());
}
}
Err(err) => {
@@ -6795,11 +10763,8 @@ impl ApplicationHandler for StaticVulkanApp {
return;
}
}
- if self.target_frames != 0 && self.lifecycle.frames_presented >= self.target_frames
- {
+ if self.target_frames != 0 && self.frames_presented >= self.target_frames {
self.complete(event_loop);
- } else if drawable_available {
- self.schedule_next_redraw();
}
}
_ => {}
@@ -6815,28 +10780,21 @@ impl ApplicationHandler for StaticVulkanApp {
};
let size = window.inner_size();
if size.width == 0 || size.height == 0 {
+ // Wait for a resize instead of spinning simulation updates while
+ // the window has no drawable surface.
event_loop.set_control_flow(ControlFlow::Wait);
+ self.pause_live_clock_at(Instant::now());
return;
}
event_loop.set_control_flow(ControlFlow::Poll);
- if self.lifecycle.pending.is_none() {
- let viewport = self
- .captured_viewport
- .map_or([size.width as f32, size.height as f32], |captured| {
- [captured.extent[0] as f32, captured.extent[1] as f32]
- });
- let sample = self.sample_frame_clock();
- let update = match self.prepare_scene_update(sample, viewport) {
- Ok(update) => update,
- Err(error) => {
- self.error = Some(error);
- event_loop.exit();
- return;
- }
- };
- if !self.lifecycle.prepare(update) {
- return;
- }
+ self.resume_live_clock_at(Instant::now());
+ let clock = self.pending_or_sample_frame_clock();
+ if let Err(error) =
+ self.prepare_scene_update(clock, [size.width as f32, size.height as f32])
+ {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
}
self.schedule_next_redraw();
}
@@ -6851,6 +10809,7 @@ struct Args {
control_clock_ms: Option<u32>,
validation: bool,
readback_out: Option<PathBuf>,
+ readback_sequence_out: Option<PathBuf>,
preview_roots: NonZeroUsize,
legacy_camera_capture: Option<PathBuf>,
static_animation_frame: Option<u16>,
@@ -6869,6 +10828,7 @@ impl Args {
let mut control_clock_ms = None;
let mut validation = false;
let mut readback_out = None;
+ let mut readback_sequence_out = None;
// A native static-Vulkan invocation is the usable mission preview, so
// its default scope covers every root. `PreviewRoots` is clamped by the
// runtime to the decoded mission length; an explicit --preview-roots N
@@ -6932,6 +10892,12 @@ impl Args {
.ok_or_else(|| "--readback-out requires a path".to_string())?,
);
}
+ "--readback-sequence-out" => {
+ readback_sequence_out =
+ Some(iter.next().map(PathBuf::from).ok_or_else(|| {
+ "--readback-sequence-out requires a path".to_string()
+ })?);
+ }
"--preview-roots" => {
preview_roots = iter
.next()
@@ -6976,6 +10942,12 @@ impl Args {
.to_string(),
);
}
+ if readback_sequence_out.is_some() && control_clock_ms.is_none() {
+ return Err(
+ "--readback-sequence-out requires --control-clock-ms for a reproducible sequence"
+ .to_string(),
+ );
+ }
Ok(Self {
root,
mission,
@@ -6984,6 +10956,7 @@ impl Args {
control_clock_ms,
validation,
readback_out,
+ readback_sequence_out,
preview_roots,
legacy_camera_capture,
static_animation_frame,
@@ -7150,205 +11123,367 @@ fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> {
}
fn usage() -> String {
- "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--control-clock-ms <u32>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally. --control-clock-ms replays one shared MSH/material/environment clock from zero to the selected time over --frames frames.".to_string()
+ "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--control-clock-ms <u32>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>] [--readback-sequence-out <directory>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally. --control-clock-ms replays one shared MSH/FX clock from zero to the selected time over the requested frames; --readback-sequence-out captures each submitted frame and requires that clock.".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
+ use fparkan_animation::{AnimKey24, Pose};
+ use fparkan_assets::{PreparedControlTimelineRow, PreparedControlTypedRecord};
use fparkan_fx::environment::{WeatherInput, WeatherState};
- use fparkan_material::{MaterialAnimationBlock, MaterialKey};
+ use fparkan_msh::{Batch, ModelAnimation, Slot};
+ use fparkan_path::{normalize_relative, PathPolicy};
+ use fparkan_resource::{resource_name, ResourceKey};
- fn material_test_phase(parameters: [u8; 18]) -> MaterialPhase {
- MaterialPhase {
- parameters,
- texture_raw: [0; 16],
- }
+ fn facing_mesh(
+ batch_flags: u16,
+ positions: &[[f32; 3]],
+ ) -> (VulkanStaticMesh, VulkanStaticDrawRange) {
+ let range = VulkanStaticDrawRange {
+ first_index: 0,
+ index_count: u32::try_from(positions.len()).expect("fixture length fits u32"),
+ material_index: 0,
+ lightmap_index: 0,
+ batch_flags,
+ pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
+ alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
+ };
+ let mesh = VulkanStaticMesh {
+ vertices: positions
+ .iter()
+ .copied()
+ .map(|position| VulkanStaticVertex {
+ position,
+ color: [1.0; 3],
+ normal: [0.0, 0.0, 1.0],
+ uv: [0.0; 2],
+ detail_uv: [0.0; 2],
+ overlay_alpha: 0.0,
+ })
+ .collect(),
+ indices: (0..positions.len())
+ .map(|index| u32::try_from(index).expect("fixture index fits u32"))
+ .collect(),
+ draw_ranges: vec![range],
+ };
+ (mesh, range)
}
- fn random_material_binding(range_index: usize) -> MaterialAnimationBinding {
- let mut overlay_phase = [0; 18];
- overlay_phase[..3].copy_from_slice(&[100, 50, 25]);
- overlay_phase[3] = 25;
- overlay_phase[4..7].copy_from_slice(&[30, 60, 90]);
- overlay_phase[8..11].copy_from_slice(&[40, 80, 120]);
- overlay_phase[16] = 5;
- let mut active_phase = [0; 18];
- active_phase[..3].copy_from_slice(&[25, 50, 75]);
- active_phase[3] = 75;
- active_phase[4..7].copy_from_slice(&[64, 128, 192]);
- active_phase[8..11].copy_from_slice(&[100, 150, 200]);
- active_phase[16] = 9;
- active_phase[17] = 1;
- let document = Mat0Document {
- version: 0,
- animation_block_count: 1,
- phases: vec![
- material_test_phase(overlay_phase),
- material_test_phase(active_phase),
+ fn native_front_triangle() -> [[f32; 3]; 3] {
+ [[-0.2, -0.2, -1.0], [0.2, -0.2, -1.0], [0.0, 0.2, -1.0]]
+ }
+
+ fn camera_face_triangle(camera: PreviewCameraFrame, mirror: bool) -> [[f32; 3]; 3] {
+ let center = add3(camera.position, scale3(camera.forward, 10.0));
+ let mirror = if mirror { -1.0 } else { 1.0 };
+ [
+ add3(
+ add3(center, scale3(camera.right, -2.0 * mirror)),
+ scale3(camera.up, -2.0),
+ ),
+ add3(
+ add3(center, scale3(camera.right, 2.0 * mirror)),
+ scale3(camera.up, -2.0),
+ ),
+ add3(center, scale3(camera.up, 2.0)),
+ ]
+ }
+
+ fn projected_vulkan_area(camera: PreviewCameraFrame, triangle: [[f32; 3]; 3]) -> f32 {
+ let screen = triangle.map(|point| {
+ let clip =
+ row_vector_transform([point[0], point[1], point[2], 1.0], camera.clip_from_world);
+ assert!(clip[3] > 0.0 && clip.iter().all(|value| value.is_finite()));
+ // This is the positive-height viewport transform used by the Vulkan renderer.
+ [
+ (clip[0] / clip[3] + 1.0) * 0.5,
+ (clip[1] / clip[3] + 1.0) * 0.5,
+ ]
+ });
+ let shoelace = (0..3)
+ .map(|index| {
+ let next = (index + 1) % 3;
+ screen[index][0] * screen[next][1] - screen[next][0] * screen[index][1]
+ })
+ .sum::<f32>()
+ * 0.5;
+ // Vulkan's framebuffer signed-area definition is the negative shoelace area.
+ -shoelace
+ }
+
+ #[test]
+ fn native_msh_cull_mode_matches_legacy_and_free_flight_camera_projection() {
+ let identity = RawCameraTransform {
+ words: [
+ 1.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 1.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 1.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 1.0_f32.to_bits(),
],
- prefix: Vec::new(),
- header_opaque: [0; 2],
- animation_blocks: vec![MaterialAnimationBlock {
- header_raw: 3,
- keys: vec![
- MaterialKey {
- k0: 0,
- k1: 20,
- k2: 0,
- },
- MaterialKey {
- k0: 1,
- k1: 40,
- k2: 0,
- },
- ],
- bytes: Vec::new(),
- }],
};
- let frozen_binding = |phase_index: usize, uv_transform: [f32; 4]| MaterialPhaseBinding {
- document: document.clone(),
- animation_block_index: 0,
- wear_row_start_ms: 0,
- random_state: 0,
- frozen_phase_index: Some(phase_index),
- phase_uv_transforms: vec![uv_transform; 2],
+ let projection = LegacyD3d7Projection {
+ viewport: [0, 0, 1280, 1024],
+ near_plane: 0.5,
+ far_plane: 700.0,
+ field_of_view_radians: 1.3,
};
- let texture = solid_environment_texture([255; 4]);
- MaterialAnimationBinding {
- initial_phase_index: 0,
- variants: vec![MaterialVariantSet {
- phase_material_indices: vec![4, 5],
- range_indices: vec![range_index],
- }],
- base: MaterialPhaseBinding {
- document: document.clone(),
- animation_block_index: 0,
- wear_row_start_ms: 0,
- random_state: 0,
- frozen_phase_index: None,
- phase_uv_transforms: vec![[0.0, 0.0, 0.5, 0.5], [0.5, 0.25, 0.25, 0.5]],
- },
- detail: Some(frozen_binding(1, [0.1, 0.2, 0.3, 0.4])),
- overlay: Some(frozen_binding(0, [0.3, 0.4, 0.5, 0.6])),
- overlay_detail: Some(frozen_binding(1, [0.6, 0.7, 0.8, 0.9])),
- stage_textures: Some(MaterialStageTextures {
- detail: Some(vec![texture.clone(), texture.clone()]),
- overlay: Some(vec![texture.clone()]),
- overlay_detail: Some(vec![texture.clone(), texture]),
- applied_material_phases: HashMap::from([(5, [1, 0, 1])]),
- }),
+ let legacy = PreviewCameraFrame::from_legacy_d3d7(identity, projection)
+ .expect("valid legacy camera projection");
+ let free_flight = FreeFlightCamera {
+ position: legacy.position,
+ yaw: 0.0,
+ pitch: 0.0,
+ horizontal_fov: 1.3,
+ near_plane: 0.5,
+ far_plane: 700.0,
+ move_speed: 1.0,
+ ground_clearance: 0.0,
}
- }
+ .preview_frame(1280.0 / 1024.0);
- fn packet_test_materials() -> Vec<VulkanStaticMaterial> {
- let mut materials = Vec::new();
- for _ in 0..6 {
- append_environment_gradient_material(&mut materials).expect("test material");
+ for camera in [legacy, free_flight] {
+ let front = camera_face_triangle(camera, false);
+ let back = [front[0], front[2], front[1]];
+ let front_d = dot3(
+ sub3(camera.position, front[0]),
+ cross3(sub3(front[1], front[0]), sub3(front[2], front[0])),
+ );
+ let back_d = dot3(
+ sub3(camera.position, back[0]),
+ cross3(sub3(back[1], back[0]), sub3(back[2], back[0])),
+ );
+ assert!(front_d > 0.0);
+ assert!(back_d < 0.0);
+
+ let front_vk_area = projected_vulkan_area(camera, front);
+ let back_vk_area = projected_vulkan_area(camera, back);
+ // Vulkan CLOCKWISE front faces have negative framebuffer area.
+ // The native-visible d>0 face is therefore a Vulkan back face.
+ assert!(front_vk_area > 0.0, "front area: {front_vk_area}");
+ assert!(back_vk_area < 0.0, "back area: {back_vk_area}");
+ assert_eq!(native_msh_cull_mode(0, 1), LegacyCullMode::FrontFace);
+
+ let mirrored_front = camera_face_triangle(camera, true);
+ let mirrored_d = dot3(
+ sub3(camera.position, mirrored_front[0]),
+ cross3(
+ sub3(mirrored_front[1], mirrored_front[0]),
+ sub3(mirrored_front[2], mirrored_front[0]),
+ ),
+ );
+ assert!(mirrored_d < 0.0);
+ assert!(projected_vulkan_area(camera, mirrored_front) < 0.0);
+ assert_eq!(native_msh_cull_mode(0, -1), LegacyCullMode::BackFace);
}
- materials[4].pipeline_category = 1;
- materials[5].pipeline_category = 3;
- materials
}
#[test]
- fn sampled_material_packet_keeps_native_selector_properties_and_stage_phases() {
- let materials = packet_test_materials();
- let mut bindings = vec![random_material_binding(7)];
- let packet = sample_material_animations(&mut bindings, &materials, 500)
- .expect("sample random MAT0 state");
+ fn native_msh_handedness_uses_full_placement_transform_and_handles_singular_scale() {
+ let rotated = LegacyIron3dEulerTransform {
+ translation: [8.0, -4.0, 3.0],
+ orientation_radians: [0.4, -0.7, 1.2],
+ };
+ assert_eq!(native_msh_handedness(rotated, [2.0, 3.0, 4.0]), 1);
+ assert_eq!(native_msh_handedness(rotated, [-2.0, 3.0, 4.0]), -1);
+ assert_eq!(native_msh_handedness(rotated, [0.0, 3.0, 4.0]), 0);
+ assert_eq!(native_msh_cull_mode(0, 0), LegacyCullMode::Disabled);
+ }
+
+ #[test]
+ fn native_msh_4000_samples_only_first_triangle_and_raw_two_sided_bypasses_it() {
+ let front = native_front_triangle();
+ let back = front.map(|point| [-point[0], point[1], point[2]]);
+ let front_then_back = [front[0], front[1], front[2], back[0], back[1], back[2]];
+ let (mesh, range) = facing_mesh(0x4000, &front_then_back);
+ assert!(native_msh_first_triangle_visible(
+ &mesh, &range, [0.0; 3], 1
+ ));
+ let back_then_front = [back[0], back[1], back[2], front[0], front[1], front[2]];
+ let (mesh, range) = facing_mesh(0x4000, &back_then_front);
+ assert!(!native_msh_first_triangle_visible(
+ &mesh, &range, [0.0; 3], 1
+ ));
+
+ let (mesh, range) = facing_mesh(0x4002, &back_then_front);
+ assert!(native_msh_first_triangle_visible(
+ &mesh, &range, [0.0; 3], 1
+ ));
assert_eq!(
- packet.range_selections,
- vec![MaterialRangeSelection {
- range_index: 7,
- material_index: 5,
- pipeline_category: 3,
- }]
- );
- assert_eq!(
- packet.active_materials.get(&7),
- Some(&ActiveMaterialState {
- material_index: 5,
- diffuse_alpha: 0.75,
- })
- );
- let properties = packet.properties[0];
- assert_eq!(
- (properties.diffuse_alpha, properties.overlay_alpha),
- (0.75, 0.25)
+ native_msh_cull_mode(range.batch_flags, 1),
+ LegacyCullMode::Disabled
);
+ }
+
+ #[test]
+ fn world_draw_range_count_restores_after_alpha_and_msh_facing_changes() {
+ let triangle = native_front_triangle();
+ let (mesh, range) = facing_mesh(0x4000, &triangle);
+ let original_count = range.index_count;
+
assert_eq!(
- (properties.base_directional, properties.base_additive),
- (
- [64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0],
- [25.0 / 255.0, 50.0 / 255.0, 75.0 / 255.0],
- )
+ world_draw_range_index_count(&mesh, &range, original_count, 0.0, [0.0; 3], Some(1)),
+ 0
);
assert_eq!(
- (properties.overlay_directional, properties.overlay_additive),
- (
- Some([30.0 / 255.0, 60.0 / 255.0, 90.0 / 255.0]),
- Some([100.0 / 255.0, 50.0 / 255.0, 25.0 / 255.0]),
- )
+ world_draw_range_index_count(
+ &mesh,
+ &range,
+ original_count,
+ 1.0,
+ [0.0, 0.0, -2.0],
+ Some(1),
+ ),
+ 0
);
assert_eq!(
- (properties.specular_rgb, properties.specular_power),
- ([100.0 / 255.0, 150.0 / 255.0, 200.0 / 255.0], 9)
+ world_draw_range_index_count(&mesh, &range, original_count, 1.0, [0.0; 3], Some(1)),
+ original_count
);
assert_eq!(
- (properties.overlay_specular_rgb, properties.overlay_power),
- (Some([40.0 / 255.0, 80.0 / 255.0, 120.0 / 255.0]), Some(5))
+ world_draw_range_index_count(
+ &mesh,
+ &range,
+ original_count,
+ 1.0,
+ [0.0, 0.0, -2.0],
+ None,
+ ),
+ original_count
);
+ }
+
+ #[test]
+ fn world_draw_range_count_clears_and_reapplies_constant_alpha_override() {
+ let triangle = native_front_triangle();
+ let (mesh, mut range) = facing_mesh(0x0148, &triangle);
+ let original_count = range.index_count;
+ let mut alpha_override = None;
+ let update = |range: &VulkanStaticDrawRange,
+ eye_world: [f32; 3],
+ alpha_override: &mut Option<f32>| {
+ let mut current_mesh = mesh.clone();
+ current_mesh.draw_ranges[0] = *range;
+ *alpha_override =
+ native_msh_diffuse_alpha_override(&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,
- indices: vec![],
- batches: vec![],
+ type16_present: false,
+ indices: vec![0, 1, 2],
+ batches: vec![Batch {
+ batch_flags: 0,
+ material_index_hi: 0,
+ material_index: 0,
+ lightmap_index: 0,
+ local_batch_index: 0,
+ index_count: 3,
+ index_start: 0,
+ vertex_count: 3,
+ base_vertex: 0,
+ }],
+ triangle_descriptors: None,
node_names: None,
- animation: Some(fparkan_msh::ModelAnimation {
+ animation: Some(ModelAnimation {
keys: vec![
- fparkan_animation::AnimKey24 {
+ AnimKey24 {
time: AnimationTime(0.0),
- pose: fparkan_animation::Pose::default(),
+ pose: Pose::default(),
},
- fparkan_animation::AnimKey24 {
+ AnimKey24 {
time: AnimationTime(1.0),
- pose: fparkan_animation::Pose {
+ pose: Pose {
+ translation: [0.0; 3],
+ rotation: [
+ 0.0,
+ 0.0,
+ std::f32::consts::FRAC_1_SQRT_2,
+ std::f32::consts::FRAC_1_SQRT_2,
+ ],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose {
translation: [0.0, 2.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
@@ -7360,59 +11495,785 @@ mod tests {
}
}
- fn control_pose_drawable_test_model() -> ModelAsset {
- let mut model = control_pose_test_model();
- model.nodes_raw[8..10].copy_from_slice(&0_u16.to_le_bytes());
- model.slots = vec![fparkan_msh::Slot {
- tri_start: 0,
- tri_count: 1,
- batch_start: 0,
- batch_count: 1,
- aabb_min: [0.0; 3],
- aabb_max: [1.0; 3],
- sphere_center: [0.0; 3],
- sphere_radius: 1.0,
- opaque: [0; 5],
- }];
- model.positions = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
- model.normals = Some(vec![[0, 0, 127, 0]; 3]);
- model.uv0 = Some(vec![[0, 0]; 3]);
- model.uv1 = None;
- model.indices = vec![0, 1, 2];
- model.batches = vec![fparkan_msh::Batch {
- batch_flags: 0,
- material_index_hi: 0,
- material_index: 0,
- lightmap_index: u8::MAX,
- local_batch_index: 0,
- index_count: 3,
- index_start: 0,
- vertex_count: 3,
- base_vertex: 0,
- }];
- model
+ fn static_child_model() -> ModelAsset {
+ let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, false);
+ nodes.extend(node38_fixture(0, u16::MAX, 0, true));
+ ModelAsset {
+ node_stride: 38,
+ node_count: 2,
+ nodes_raw: nodes,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 1.0,
+ slots: vec![Slot {
+ tri_start: 0,
+ tri_count: 1,
+ batch_start: 0,
+ batch_count: 1,
+ aabb_min: [-1.0; 3],
+ aabb_max: [1.0; 3],
+ sphere_center: [0.0; 3],
+ sphere_radius: 1.0,
+ opaque: [0; 5],
+ }],
+ positions: vec![[0.0, 0.0, 0.0]; 3],
+ normals: Some(vec![[0, 0, 127, 0]; 3]),
+ uv0: None,
+ uv1: None,
+ type16_present: false,
+ indices: vec![0, 1, 2],
+ batches: vec![Batch {
+ batch_flags: 0,
+ material_index_hi: 0,
+ material_index: 0,
+ lightmap_index: 0,
+ local_batch_index: 0,
+ index_count: 3,
+ index_start: 0,
+ vertex_count: 3,
+ base_vertex: 0,
+ }],
+ triangle_descriptors: None,
+ node_names: None,
+ animation: Some(ModelAnimation {
+ keys: vec![AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose::default(),
+ }],
+ frame_map: vec![],
+ frame_count: 0,
+ }),
+ }
+ }
+
+ fn collision_fixture_model(root_drawable: bool) -> ModelAsset {
+ let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, root_drawable);
+ nodes.extend(node38_fixture(0, 0, 1, true));
+ ModelAsset {
+ node_stride: 38,
+ node_count: 2,
+ nodes_raw: nodes,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 3.0,
+ slots: vec![Slot {
+ tri_start: 0,
+ tri_count: 1,
+ batch_start: 0,
+ batch_count: 1,
+ aabb_min: [-2.0, 0.0, -2.0],
+ aabb_max: [2.0, 0.0, 2.0],
+ sphere_center: [0.0; 3],
+ sphere_radius: 2.0,
+ opaque: [0; 5],
+ }],
+ positions: vec![[-2.0, 0.0, -2.0], [0.0, 0.0, 2.0], [2.0, 0.0, -2.0]],
+ normals: Some(vec![[0, 127, 0, 0]; 3]),
+ uv0: None,
+ uv1: None,
+ type16_present: false,
+ indices: vec![0, 1, 2],
+ batches: vec![Batch {
+ batch_flags: 0,
+ material_index_hi: 0,
+ material_index: 0,
+ lightmap_index: 0,
+ local_batch_index: 0,
+ index_count: 3,
+ index_start: 0,
+ vertex_count: 3,
+ base_vertex: 0,
+ }],
+ triangle_descriptors: Some(vec![fparkan_msh::TriangleDescriptor {
+ tri_flags: 0,
+ opaque: [0; 4],
+ normal: [0, 32767, 0],
+ }]),
+ node_names: None,
+ animation: Some(ModelAnimation {
+ keys: vec![
+ AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose::default(),
+ },
+ AnimKey24 {
+ time: AnimationTime(1.0),
+ pose: Pose {
+ translation: [20.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ ],
+ frame_map: vec![0, 1],
+ frame_count: 2,
+ }),
+ }
+ }
+
+ #[test]
+ fn mounted_static_building_binding_matches_renderer_and_hides_root_node() {
+ let model = collision_fixture_model(true);
+ let binding = building_collision_binding(
+ 4,
+ 5,
+ "static fixture model".to_string(),
+ Arc::new(model.clone()),
+ LegacyIron3dEulerTransform {
+ translation: [13.0, -4.0, 9.0],
+ orientation_radians: [0.4, -0.3, 0.7],
+ },
+ [2.0, 3.0, 0.5],
+ true,
+ )
+ .expect("static building geometry still creates a collision binding");
+ assert_eq!(
+ binding.selected_nodes,
+ vec![(fparkan_msh::NodeId(1), fparkan_msh::SlotId(0))]
+ );
+
+ let root = VulkanNodePose {
+ translation: [1.0, 2.0, 3.0],
+ rotation: [0.0, 0.0, 0.29552022, 0.9553365],
+ };
+ let moving_node = compose_unit_pose(
+ root,
+ VulkanNodePose {
+ translation: [2.0, -1.0, 0.5],
+ rotation: [0.0, 0.19866933, 0.0, 0.9800666],
+ },
+ );
+ let hierarchy = NodePoseBuffer {
+ poses: [root, moving_node]
+ .into_iter()
+ .map(|pose| Pose {
+ translation: pose.translation,
+ rotation: pose.rotation,
+ })
+ .collect(),
+ };
+ let mount = VulkanNodePose {
+ translation: [5.0, 7.0, 11.0],
+ rotation: [0.0, 0.0, 0.38268343, 0.9238795],
+ };
+ let nodes = building_collision_nodes(&binding, Some(&hierarchy), Some(mount))
+ .expect("mounted collision matrices");
+ assert_eq!(nodes.len(), 1);
+ assert_eq!(nodes[0].node, fparkan_msh::NodeId(1));
+
+ let projected = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
+ &model,
+ binding.root_transform,
+ binding.owner_scale,
+ &hierarchy,
+ mount,
+ )
+ .expect("mounted renderer projection");
+ assert_eq!(projected.vertices.len(), 3);
+ for (actual, source) in projected.vertices.iter().zip(&model.positions) {
+ let matrix = nodes[0].world_from_node;
+ let expected = [
+ matrix[0] * source[0] + matrix[1] * source[1] + matrix[2] * source[2] + matrix[3],
+ matrix[4] * source[0] + matrix[5] * source[1] + matrix[6] * source[2] + matrix[7],
+ matrix[8] * source[0] + matrix[9] * source[1] + matrix[10] * source[2] + matrix[11],
+ ];
+ for axis in 0..3 {
+ assert!((actual.position[axis] - expected[axis]).abs() < 1.0e-4);
+ }
+ }
+ }
+
+ fn doorway_collision_model() -> ModelAsset {
+ let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, false);
+ nodes.extend(node38_fixture(0, u16::MAX, 0, true));
+ ModelAsset {
+ node_stride: 38,
+ node_count: 2,
+ nodes_raw: nodes,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 5.0,
+ slots: vec![Slot {
+ tri_start: 0,
+ tri_count: 4,
+ batch_start: 0,
+ batch_count: 1,
+ aabb_min: [-4.0, 0.0, -2.0],
+ aabb_max: [4.0, 0.0, 2.0],
+ sphere_center: [0.0; 3],
+ sphere_radius: 5.0,
+ opaque: [0; 5],
+ }],
+ positions: vec![
+ [-4.0, 0.0, -2.0],
+ [-1.0, 0.0, -2.0],
+ [-1.0, 0.0, 2.0],
+ [-4.0, 0.0, 2.0],
+ [1.0, 0.0, -2.0],
+ [4.0, 0.0, -2.0],
+ [4.0, 0.0, 2.0],
+ [1.0, 0.0, 2.0],
+ ],
+ normals: None,
+ uv0: None,
+ uv1: None,
+ type16_present: false,
+ indices: vec![0, 3, 2, 0, 2, 1, 4, 7, 6, 4, 6, 5],
+ batches: vec![Batch {
+ batch_flags: 0,
+ material_index_hi: 0,
+ material_index: 0,
+ lightmap_index: 0,
+ local_batch_index: 0,
+ index_count: 12,
+ index_start: 0,
+ vertex_count: 8,
+ base_vertex: 0,
+ }],
+ triangle_descriptors: Some(
+ (0..4)
+ .map(|_| fparkan_msh::TriangleDescriptor {
+ tri_flags: 0,
+ opaque: [0; 4],
+ normal: [0, 32767, 0],
+ })
+ .collect(),
+ ),
+ node_names: None,
+ animation: Some(ModelAnimation {
+ keys: vec![AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose::default(),
+ }],
+ frame_map: Vec::new(),
+ frame_count: 0,
+ }),
+ }
+ }
+
+ fn collision_test_app(
+ model: Arc<ModelAsset>,
+ root_transform: LegacyIron3dEulerTransform,
+ position: [f32; 3],
+ ) -> StaticVulkanApp {
+ let (mut app, _) = clock_test_app(None);
+ let hierarchy = static_preview_component_pose_buffer(&model, &[], None)
+ .expect("fixture static preview pose")
+ .expect("fixture Node38 pose");
+ app.component_pose_buffers = vec![vec![Some(hierarchy)]];
+ app.component_mount_poses = vec![vec![None]];
+ app.dynamic_component_geometry = vec![vec![None]];
+ app.control_animation_instances = vec![vec![None]];
+ app.building_collision_bindings = vec![building_collision_binding(
+ 0,
+ 0,
+ "collision fixture".to_string(),
+ Arc::clone(&model),
+ root_transform,
+ [1.0; 3],
+ false,
+ )
+ .expect("fixture building collision binding")];
+ app.free_camera = Some(FreeFlightCamera {
+ position,
+ yaw: -std::f32::consts::FRAC_PI_2,
+ pitch: 0.0,
+ horizontal_fov: 1.3,
+ near_plane: 0.5,
+ far_plane: 700.0,
+ move_speed: 60.0,
+ ground_clearance: 0.0,
+ });
+ app.pressed_keys.insert(KeyCode::KeyW);
+ app
+ }
+
+ #[test]
+ fn high_speed_free_flight_stops_at_static_building_wall() {
+ let mut app = collision_test_app(
+ Arc::new(collision_fixture_model(false)),
+ LegacyIron3dEulerTransform {
+ translation: [0.0, 5.0, 0.0],
+ orientation_radians: [0.0; 3],
+ },
+ [0.0, 8.0, 0.0],
+ );
+ app.update_free_camera(0.1, [1280.0, 720.0])
+ .expect("continuous building query");
+ assert_eq!(app.free_camera.unwrap().position, [0.0, 8.0, 0.0]);
+ }
+
+ #[test]
+ fn small_nonzero_building_scale_remains_queryable() {
+ let model = Arc::new(collision_fixture_model(false));
+ let binding = building_collision_binding(
+ 0,
+ 0,
+ "tiny-scale fixture".to_string(),
+ Arc::clone(&model),
+ LegacyIron3dEulerTransform {
+ translation: [0.0, 5.0, 0.0],
+ orientation_radians: [0.0; 3],
+ },
+ [1.0e-8, 1.0, 1.0],
+ false,
+ )
+ .expect("small nonzero scale is invertible");
+ let hierarchy = static_preview_component_pose_buffer(&model, &[], None)
+ .expect("fixture static preview pose")
+ .expect("fixture Node38 pose");
+ let nodes = building_collision_nodes(&binding, Some(&hierarchy), None)
+ .expect("small-scale collision matrix");
+ assert!(query_building_segment_world(
+ &binding.model,
+ &nodes,
+ [[0.0, 8.0, 0.0], [0.0, 2.0, 0.0]],
+ )
+ .expect("small-scale query")
+ .is_some());
+ }
+
+ #[test]
+ fn free_flight_passes_through_authored_doorway() {
+ let mut app = collision_test_app(
+ Arc::new(doorway_collision_model()),
+ LegacyIron3dEulerTransform {
+ translation: [0.0, 5.0, 0.0],
+ orientation_radians: [0.0; 3],
+ },
+ [0.0, 8.0, 0.0],
+ );
+ app.update_free_camera(0.1, [1280.0, 720.0])
+ .expect("doorway query");
+ let position = app.free_camera.unwrap().position;
+ assert!((position[1] - 2.0).abs() < 1.0e-4);
+ }
+
+ #[test]
+ fn shared_building_models_keep_each_placed_instance_query_separate() {
+ let model = Arc::new(collision_fixture_model(false));
+ let far = building_collision_binding(
+ 0,
+ 0,
+ "shared fixture".to_string(),
+ Arc::clone(&model),
+ LegacyIron3dEulerTransform {
+ translation: [0.0, 100.0, 0.0],
+ orientation_radians: [0.0; 3],
+ },
+ [1.0; 3],
+ false,
+ )
+ .expect("first placed building");
+ let near = building_collision_binding(
+ 1,
+ 0,
+ "shared fixture".to_string(),
+ Arc::clone(&model),
+ LegacyIron3dEulerTransform {
+ translation: [0.0, 5.0, 0.0],
+ orientation_radians: [0.0; 3],
+ },
+ [1.0; 3],
+ false,
+ )
+ .expect("second placed building");
+ assert!(Arc::ptr_eq(&far.model, &near.model));
+
+ let (mut app, _) = clock_test_app(None);
+ let hierarchy = static_preview_component_pose_buffer(&model, &[], None)
+ .expect("shared fixture static pose")
+ .expect("shared fixture Node38 pose");
+ app.component_pose_buffers = vec![vec![Some(hierarchy.clone())], vec![Some(hierarchy)]];
+ app.component_mount_poses = vec![vec![None], vec![None]];
+ app.building_collision_bindings = vec![far, near];
+ app.free_camera.as_mut().unwrap().position = [0.0, 8.0, 0.0];
+ app.free_camera.as_mut().unwrap().yaw = -std::f32::consts::FRAC_PI_2;
+ app.free_camera.as_mut().unwrap().pitch = 0.0;
+ app.free_camera.as_mut().unwrap().move_speed = 60.0;
+ app.pressed_keys.insert(KeyCode::KeyW);
+ app.update_free_camera(0.1, [1280.0, 720.0])
+ .expect("both placed buildings are queried independently");
+ assert_eq!(app.free_camera.unwrap().position, [0.0, 8.0, 0.0]);
+ }
+
+ #[test]
+ fn collision_uses_updated_control_pose_and_retries_do_not_move_again() {
+ let model = Arc::new(collision_fixture_model(false));
+ let mut app = collision_test_app(
+ Arc::clone(&model),
+ LegacyIron3dEulerTransform {
+ translation: [0.0, 5.0, 0.0],
+ orientation_radians: [0.0; 3],
+ },
+ [0.0, 8.0, 0.0],
+ );
+ let mut control = socket_timeline_control();
+ control.timeline_rows[0].node_index = 1;
+ app.control_animation_instances[0][0] = Some(ControlAnimationInstance {
+ control,
+ model: (*model).clone(),
+ gate_values: vec![Some(0.0)],
+ state: ControlInstanceState::default(),
+ last_sampled_node_times: Vec::new(),
+ reported_unsupported_fallback: false,
+ });
+
+ let first = FrameClockSample {
+ id: SceneSampleId::Replay(0),
+ time_ms: 0,
+ delta_seconds: 0.1,
+ };
+ app.prepare_scene_update(first, [1280.0, 720.0])
+ .expect("closed moving wall blocks the first frame");
+ let safe_position = app.free_camera.unwrap().position;
+ let closed_pose = app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation;
+ assert_eq!(safe_position, [0.0, 8.0, 0.0]);
+
+ app.control_animation_instances[0][0]
+ .as_mut()
+ .unwrap()
+ .gate_values[0] = Some(1.0);
+ app.prepare_scene_update(first, [1024.0, 768.0])
+ .expect("pending retry only refreshes its viewport");
+ assert_eq!(app.free_camera.unwrap().position, safe_position);
+ assert_eq!(
+ app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation,
+ closed_pose
+ );
+
+ app.prepare_scene_update(
+ FrameClockSample {
+ id: SceneSampleId::Replay(1),
+ time_ms: 1000,
+ delta_seconds: 0.1,
+ },
+ [1024.0, 768.0],
+ )
+ .expect("the next sampled pose opens the path");
+ let open_pose = app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation;
+ assert!(open_pose[0] > 2.0, "door node did not move: {open_pose:?}");
+ assert!((app.free_camera.unwrap().position[1] - 2.0).abs() < 1.0e-4);
+ }
+
+ #[test]
+ fn missing_free_camera_keeps_legacy_view_without_querying_buildings() {
+ let (mut app, _) = clock_test_app(None);
+ app.free_camera = None;
+ app.building_collision_bindings
+ .push(BuildingCollisionBinding {
+ object_index: 0,
+ component_index: 0,
+ model_source: "legacy fixture".to_string(),
+ model: Arc::new(collision_fixture_model(false)),
+ root_transform: LegacyIron3dEulerTransform {
+ translation: [0.0; 3],
+ orientation_radians: [0.0; 3],
+ },
+ owner_scale: [1.0; 3],
+ mounted: false,
+ selected_nodes: vec![(NodeId(u32::MAX), SlotId(u32::MAX))],
+ });
+ let camera = app.camera;
+ let view_camera = app.view_camera;
+ app.update_free_camera(0.1, [1024.0, 768.0])
+ .expect("legacy camera skips free-flight collision");
+ assert_eq!(app.camera, camera);
+ assert_eq!(app.view_camera, view_camera);
}
- fn control_pose_test_control() -> PreparedComponentControl {
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_plant_wall_and_door_use_the_app_camera_bridge() {
+ use std::path::PathBuf;
+
+ const BODY_WALL: [[f32; 3]; 2] = [[-10.5, 22.16, 2.5], [-10.5, 19.16, 2.5]];
+ const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]];
+ let root = PathBuf::from(
+ std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed tests"),
+ );
+ let mission = "MISSIONS/Autodemo.00/data.tma";
+ let args = Args::parse(&[
+ "--root".to_string(),
+ root.to_string_lossy().into_owned(),
+ "--mission".to_string(),
+ mission.to_string(),
+ ])
+ .expect("licensed AutoDemo app arguments");
+ let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(&args.root))))
+ .expect("create licensed AutoDemo engine");
+ let loaded = load_requested_mission(&mut engine, &args).expect("load AutoDemo mission");
+ let assets = loaded_mission_assets(&engine).expect("AutoDemo assets");
+ let terrain = loaded_terrain(&engine).expect("AutoDemo terrain");
+ let roots = loaded_mission_object_drafts(&engine).expect("AutoDemo object drafts");
+ let preview = static_preview_mesh_and_materials(
+ assets,
+ terrain,
+ roots,
+ None,
+ None,
+ None,
+ &args.root,
+ &loaded.land_msh_path,
+ )
+ .expect("build AutoDemo static preview");
+ let mut component_bindings = preview
+ .building_collision_bindings
+ .iter()
+ .map(|binding| (binding.object_index, binding.component_index))
+ .collect::<Vec<_>>();
+ component_bindings.sort_unstable();
+ assert_eq!(
+ component_bindings,
+ vec![(4, 0), (4, 5), (5, 0), (5, 5), (6, 0), (7, 0)]
+ );
+
+ let shadow_range = EnvironmentGpuRange {
+ range_index: preview.mesh.draw_ranges.len(),
+ vertex_start: 0,
+ vertex_capacity: 0,
+ material_index: preview.shadow_material_index,
+ };
+ let mut app = StaticVulkanApp::new(
+ preview,
+ None,
+ None,
+ shadow_range,
+ 0,
+ 0,
+ None,
+ 2,
+ mission,
+ loaded.object_count,
+ None,
+ None,
+ false,
+ None,
+ StartupTimings::default(),
+ );
+ let plant = app
+ .building_collision_bindings
+ .iter()
+ .find(|binding| binding.object_index == 4 && binding.component_index == 0)
+ .cloned()
+ .expect("static PLANT root collision binding");
+ let hierarchy = app.component_pose_buffers[4][0]
+ .as_ref()
+ .expect("PLANT current root pose");
+ let plant_nodes =
+ building_collision_nodes(&plant, Some(hierarchy), app.component_mount_poses[4][0])
+ .expect("PLANT current collision nodes");
+ let plant_node = plant_nodes
+ .iter()
+ .copied()
+ .find(|node| node.node == NodeId(1) && node.slot == SlotId(0))
+ .expect("native fixture node and slot");
+ let body_to_world = |point| {
+ plant
+ .root_transform
+ .try_transform_scaled_point(point, plant.owner_scale)
+ .expect("finite placed-body transform")
+ };
+ let world_wall = BODY_WALL.map(body_to_world);
+ let world_door = BODY_DOOR.map(body_to_world);
+ let direct_wall_hit = query_building_segment_world(&plant.model, &[plant_node], world_wall)
+ .expect("direct PLANT body-space query")
+ .expect("verified PLANT wall hit");
+ assert_eq!(direct_wall_hit.node, NodeId(1));
+ assert_eq!(direct_wall_hit.slot, SlotId(0));
+ assert_eq!(direct_wall_hit.batch_index, 5);
+ assert_eq!(direct_wall_hit.triangle_descriptor_index, 62);
+ let closed_door_hit = query_building_segment_world(&plant.model, &plant_nodes, world_door)
+ .expect("linked PLANT door query")
+ .expect("the default Node3 door blocks the center path");
+ assert_eq!(closed_door_hit.node, NodeId(3));
+ assert_eq!(closed_door_hit.batch_index, 100);
+ assert_eq!(closed_door_hit.triangle_descriptor_index, 855);
+ let terrain_bounds = app
+ .terrain
+ .surface_bounds()
+ .expect("AutoDemo terrain surface bounds");
+ for point in world_wall.into_iter().chain(world_door) {
+ for axis in 0..2 {
+ assert!(
+ (terrain_bounds[0][axis]..=terrain_bounds[1][axis]).contains(&point[axis]),
+ "native fixture endpoint {point:?} is outside terrain bounds {terrain_bounds:?}"
+ );
+ }
+ let floor = app
+ .terrain
+ .height_at([point[0], point[1]])
+ .expect("fixture terrain query");
+ if let Some(floor) = floor {
+ assert!(
+ point[2] >= floor,
+ "native fixture height {} is below terrain {floor} at {point:?}",
+ point[2]
+ );
+ } else {
+ let foundation = app
+ .terrain
+ .basement_foundation()
+ .expect("missing terrain height needs a BAS foundation");
+ assert!(!foundation.fragments.is_empty());
+ let source_floor = terrain
+ .height_at([point[0], point[1]])
+ .expect("source fixture terrain query")
+ .expect("BAS-cut fixture endpoint has source Land.msh terrain");
+ eprintln!(
+ "native fixture endpoint {point:?} has source Land.msh floor {source_floor} but no BAS-foundation surface"
+ );
+ }
+ let mut constrained = FreeFlightCamera {
+ position: point,
+ yaw: 0.0,
+ pitch: 0.0,
+ horizontal_fov: 1.3,
+ near_plane: 0.5,
+ far_plane: 700.0,
+ move_speed: 0.0,
+ ground_clearance: 0.0,
+ };
+ constrained
+ .keep_above_terrain(&app.terrain)
+ .expect("terrain constraint preserves native fixture height");
+ assert_eq!(constrained.position, point);
+ }
+
+ set_collision_test_segment(&mut app, world_wall);
+ let zero_delta = FrameClockSample {
+ id: SceneSampleId::Replay(0),
+ time_ms: 0,
+ delta_seconds: 0.0,
+ };
+ app.prepare_scene_update(zero_delta, [1280.0, 720.0])
+ .expect("zero-delta resume initialization");
+ assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
+ assert_eq!(app.record_presented_scene_frame(), 0);
+
+ let wall_frame = FrameClockSample {
+ id: SceneSampleId::Replay(1),
+ time_ms: 0,
+ delta_seconds: 0.1,
+ };
+ app.prepare_scene_update(wall_frame, [1280.0, 720.0])
+ .expect("high-speed wall movement through the app update path");
+ assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
+ app.pressed_keys.clear();
+ app.pressed_keys.insert(KeyCode::KeyD);
+ app.prepare_scene_update(wall_frame, [1024.0, 768.0])
+ .expect("retry of the pending wall frame");
+ assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
+ assert_eq!(app.record_presented_scene_frame(), 1);
+
+ set_collision_test_segment(&mut app, world_door);
+ app.prepare_scene_update(
+ FrameClockSample {
+ id: SceneSampleId::Replay(2),
+ time_ms: 0,
+ delta_seconds: 0.1,
+ },
+ [1024.0, 768.0],
+ )
+ .expect("the closed Node3 door blocks through the app update path");
+ assert_eq!(app.free_camera.unwrap().position, world_door[0]);
+ assert_eq!(app.record_presented_scene_frame(), 2);
+
+ let control = assets
+ .object_component_controls
+ .get(4)
+ .and_then(|components| components.first())
+ .and_then(Option::as_ref)
+ .expect("PLANT root retains its CTLD control");
+ let node3_binding = control
+ .bindings
+ .iter()
+ .find(|binding| binding.node_index == 3)
+ .expect("PLANT CTLD binding for the authored Node3 door");
+ assert_eq!(node3_binding.frame_a, 0.0);
+ assert_eq!(node3_binding.frame_b, 1.0);
+ assert_eq!(node3_binding.initial_blend, 0.0);
+ assert_eq!(
+ control
+ .typed_records
+ .iter()
+ .filter(|record| record.tag == 21)
+ .count(),
+ 0,
+ "the PLANT control has no tag-21 record to advance Node3 from its initial frame"
+ );
+ let mut open_node_times =
+ static_preview_node_times(plant.model.node_count, &control.bindings)
+ .expect("actual initial PLANT CTLD node times");
+ assert_eq!(open_node_times[3], Some(AnimationTime(0.0)));
+ // This samples the door's authored frame B directly; it does not simulate a mission gate.
+ open_node_times[3] = Some(AnimationTime(1.0));
+ let open_hierarchy = node38_sampled_hierarchy_at_times(&plant.model, &open_node_times)
+ .expect("sample the actual PLANT model at its authored Node3 frame B");
+ app.component_pose_buffers[4][0] = Some(open_hierarchy);
+ update_dynamic_component_geometry(
+ &app.dynamic_component_geometry,
+ &app.component_pose_buffers,
+ &mut app.component_mount_poses,
+ &mut app.mesh,
+ )
+ .expect("update the scene hierarchy for the authored door pose");
+ let open_hierarchy = app.component_pose_buffers[4][0]
+ .as_ref()
+ .expect("sampled PLANT root pose");
+ let open_nodes = building_collision_nodes(
+ &plant,
+ Some(open_hierarchy),
+ app.component_mount_poses[4][0],
+ )
+ .expect("sampled PLANT collision nodes");
+ assert!(
+ query_building_segment_world(&plant.model, &open_nodes, world_door)
+ .expect("open PLANT door query")
+ .is_none(),
+ "the authored Node3 time-1 pose leaves the doorway ray clear"
+ );
+
+ set_collision_test_segment(&mut app, world_door);
+ app.update_free_camera(0.1, [1024.0, 768.0])
+ .expect("open doorway movement through the app camera update");
+ let position = app.free_camera.unwrap().position;
+ for axis in 0..3 {
+ assert!((position[axis] - world_door[1][axis]).abs() < 1.0e-4);
+ }
+ }
+
+ fn set_collision_test_segment(app: &mut StaticVulkanApp, segment: [[f32; 3]; 2]) {
+ let delta = sub3(segment[1], segment[0]);
+ let horizontal_distance = (delta[0] * delta[0] + delta[1] * delta[1]).sqrt();
+ app.free_camera = Some(FreeFlightCamera {
+ position: segment[0],
+ yaw: delta[1].atan2(delta[0]),
+ pitch: delta[2].atan2(horizontal_distance),
+ horizontal_fov: 1.3,
+ near_plane: 0.5,
+ far_plane: 700.0,
+ move_speed: length3(delta).expect("nonzero native test segment") / 0.1,
+ ground_clearance: 0.0,
+ });
+ app.pressed_keys.clear();
+ app.pressed_keys.insert(KeyCode::KeyW);
+ }
+
+ fn socket_timeline_control() -> PreparedComponentControl {
let mut fixed_words = [0; 44];
fixed_words[2] = 0;
fixed_words[6] = u32::MAX;
fixed_words[9] = 1.0_f32.to_bits();
fixed_words[10] = 1.0_f32.to_bits();
- let archive = || {
- fparkan_path::normalize_relative(b"test.rlb", fparkan_path::PathPolicy::HostCompatible)
- .expect("test archive path")
- };
PreparedComponentControl {
- source: fparkan_resource::ResourceKey {
- archive: archive(),
- name: ResourceName(b"animated.ctl".to_vec()),
+ source: ResourceKey {
+ archive: normalize_relative(b"test.rlb", PathPolicy::HostCompatible)
+ .expect("archive path"),
+ name: resource_name(b"animated.ctl"),
type_id: None,
},
- model_node_count: 1,
native_profile_words: [0; 27],
+ model_node_count: 2,
bindings: vec![],
- timeline_rows: vec![fparkan_assets::PreparedControlTimelineRow {
+ timeline_rows: vec![PreparedControlTimelineRow {
row_index: 0,
node_index: 0,
frame_a: 0.0,
@@ -7421,16 +12282,17 @@ mod tests {
raw_parameters: [u32::MAX, u32::MAX, 1.0_f32.to_bits(), 0],
flags: 0,
}],
- typed_records: vec![fparkan_assets::PreparedControlTypedRecord {
+ typed_records: vec![PreparedControlTypedRecord {
tag: 21,
fixed_words,
values: vec![0],
string_bytes: vec![],
gate_provider: PreparedControlGateProvider::Resolved {
key: 0,
- ndp_source: fparkan_resource::ResourceKey {
- archive: archive(),
- name: ResourceName(b"animated.ndp".to_vec()),
+ ndp_source: ResourceKey {
+ archive: normalize_relative(b"test.rlb", PathPolicy::HostCompatible)
+ .expect("NDP archive path"),
+ name: resource_name(b"animated.ndp"),
type_id: None,
},
row_count: 1,
@@ -7438,595 +12300,622 @@ mod tests {
},
}],
node_data: None,
- effect_section: fparkan_assets::PreparedControlEffectSection::Absent,
+ effect_section: PreparedControlEffectSection::Absent,
initial_effect_group: None,
point_list: None,
}
}
+ fn control_point(position: [f32; 3], direction: [f32; 3]) -> PreparedControlPoint {
+ PreparedControlPoint {
+ raw_flags: 0,
+ raw_signed_field_04: 0,
+ raw_signed_field_08: 0,
+ position,
+ direction,
+ name: [0; 32],
+ }
+ }
+
+ fn assert_matrix_columns(matrix: [[f32; 4]; 4], expected: [[f32; 3]; 3]) {
+ for (column, expected) in expected.into_iter().enumerate() {
+ for row in 0..3 {
+ assert!(
+ (matrix[row][column] - expected[row]).abs() < 1.0e-5,
+ "column {column} row {row}: {} != {}",
+ matrix[row][column],
+ expected[row]
+ );
+ }
+ }
+ }
+
+ fn write_effect_u32(body: &mut [u8], offset: usize, value: u32) {
+ body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn write_effect_f32(body: &mut [u8], offset: usize, value: f32) {
+ body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn write_effect_vec3(body: &mut [u8], offset: usize, value: [f32; 3]) {
+ for (axis, component) in value.into_iter().enumerate() {
+ write_effect_f32(body, offset + axis * 4, component);
+ }
+ }
+
#[test]
- fn control_timeline_and_ndp_gate_change_component_pose_at_sampled_time() {
- let model = control_pose_test_model();
- let control = control_pose_test_control();
+ fn tag21_clock_moves_msh_vertices_normals_child_socket_and_fx_anchor() {
+ let parent_model = animated_socket_model();
+ let child_model = static_child_model();
+ let identity_transform = LegacyIron3dEulerTransform {
+ translation: [0.0; 3],
+ orientation_radians: [0.0; 3],
+ };
+ let initial_parent_pose =
+ node38_sampled_hierarchy_at_times(&parent_model, &[Some(AnimationTime(0.0)), None])
+ .expect("initial parent pose");
+ let initial_child_pose =
+ node38_fallback_hierarchy(&child_model).expect("static child pose");
+ let initial_mount =
+ node38_pose_from_hierarchy(&initial_parent_pose, 1).expect("parent socket pose");
+ let parent_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
+ &parent_model,
+ identity_transform,
+ [1.0; 3],
+ &initial_parent_pose,
+ )
+ .expect("initial parent mesh");
+ let child_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
+ &child_model,
+ identity_transform,
+ [1.0; 3],
+ &initial_child_pose,
+ initial_mount,
+ )
+ .expect("initial mounted child mesh");
+ let parent_vertex_count = parent_mesh.vertices.len();
+ assert_eq!(parent_vertex_count, 3);
+ assert_eq!(child_mesh.vertices.len(), 3);
+ let mut vertices = parent_mesh
+ .vertices
+ .into_iter()
+ .chain(child_mesh.vertices)
+ .collect::<Vec<_>>();
+ let environment_vertex_start =
+ u32::try_from(vertices.len()).expect("environment vertex base fits u32");
+ let mut environment_vertices = shadow_placeholder_vertices();
+ for vertex in &mut environment_vertices {
+ vertex.position[0] += 1000.0;
+ }
+ vertices.extend(environment_vertices);
+ let mut indices = vec![0, 1, 2, 3, 4, 5];
+ indices.extend(
+ quad_indices(1)
+ .expect("environment quad indices")
+ .into_iter()
+ .map(|index| index + environment_vertex_start),
+ );
+ let draw_range = |first_index, index_count| VulkanStaticDrawRange {
+ first_index,
+ index_count,
+ material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: world_pipeline_state(
+ LegacyBlendMode::Opaque,
+ LegacyDepthMode::TestReadOnly,
+ ),
+ terrain_base_only: false,
+ alpha_test_reference: 0,
+ };
+ let mut mesh = VulkanStaticMesh {
+ vertices,
+ indices,
+ draw_ranges: vec![draw_range(0, 3), draw_range(3, 3), draw_range(6, 6)],
+ };
+ let control = socket_timeline_control();
let gate_values = initial_control_gate_values(&control);
- let mut raw_state = ControlInstanceState::default();
- let raw_initial = sample_control_node_times(&control, 0, &gate_values, &mut raw_state)
- .expect("initial raw control timeline");
- let raw_later = sample_control_node_times(&control, 225, &gate_values, &mut raw_state)
- .expect("later raw control timeline");
- assert_eq!(raw_initial, vec![Some(0.0)]);
- assert!((raw_later[0].expect("node 0 time") - 0.225).abs() < 1.0e-5);
-
- let mut state = ControlInstanceState::default();
- let initial = static_preview_control_pose_buffer(&model, &control, 0, &mut state)
- .expect("initial control pose")
- .expect("sampled initial hierarchy");
- let later = static_preview_control_pose_buffer(&model, &control, 225, &mut state)
- .expect("later control pose")
- .expect("sampled later hierarchy");
-
- assert_eq!(initial.poses[0].translation, [0.0; 3]);
- assert!(
- (later.poses[0].translation[1] - 0.45).abs() < 1.0e-4,
- "later sampled pose: {:?}",
- later.poses[0]
- );
-
- let mut closed_gate = control.clone();
- let ndp_source = match &control.typed_records[0].gate_provider {
- PreparedControlGateProvider::Resolved { ndp_source, .. } => ndp_source.clone(),
- _ => panic!("fixture has a resolved NDP source"),
- };
- closed_gate.typed_records[0].gate_provider = PreparedControlGateProvider::Resolved {
- key: 0,
- ndp_source,
- row_count: 1,
- initial_type1_value: 0.0,
- };
- let mut closed_state = ControlInstanceState::default();
- let closed_initial =
- static_preview_control_pose_buffer(&model, &closed_gate, 0, &mut closed_state)
- .expect("closed-gate initial pose")
- .expect("closed-gate initial hierarchy");
- let closed_later =
- static_preview_control_pose_buffer(&model, &closed_gate, 225, &mut closed_state)
- .expect("closed-gate control pose")
- .expect("closed-gate hierarchy");
- assert_eq!(initial.poses[0], closed_initial.poses[0]);
- assert_eq!(closed_initial.poses[0], closed_later.poses[0]);
- assert_ne!(later.poses[0], closed_later.poses[0]);
-
- let mut mismatched_control = control.clone();
- mismatched_control.model_node_count += 1;
- let mismatch = static_preview_control_pose_buffer(
- &model,
- &mismatched_control,
- 225,
- &mut ControlInstanceState::default(),
+ let mut shadow_casters = vec![test_shadow_caster(0, [0.0; 3])];
+ shadow_casters[0].triangles = static_mesh_shadow_triangles_in_index_range(&mesh, 0, 6)
+ .expect("initial caster triangle geometry");
+ let dynamic_shadow_casters = [DynamicShadowCaster {
+ caster_index: 0,
+ object_index: 0,
+ root_transform: identity_transform,
+ owner_scale: [1.0; 3],
+ initial_sphere: shadow_casters[0].sphere,
+ component_draw_range_indices: vec![0, 1],
+ component_spheres: vec![
+ DynamicShadowSphereSource {
+ component_index: 0,
+ center: [0.0; 3],
+ radius: 1.0,
+ mounted: false,
+ },
+ DynamicShadowSphereSource {
+ component_index: 1,
+ center: [0.0; 3],
+ radius: 1.0,
+ mounted: true,
+ },
+ ],
+ }];
+ let mut instances = vec![vec![
+ Some(ControlAnimationInstance {
+ control,
+ model: parent_model.clone(),
+ gate_values,
+ state: ControlInstanceState::default(),
+ last_sampled_node_times: Vec::new(),
+ reported_unsupported_fallback: false,
+ }),
+ None,
+ ]];
+ let mut poses = vec![vec![Some(initial_parent_pose), Some(initial_child_pose)]];
+ let mut mounts = vec![vec![None, Some(initial_mount)]];
+ let geometry = vec![vec![
+ Some(DynamicComponentGeometry {
+ model: parent_model,
+ root_transform: identity_transform,
+ owner_scale: [1.0; 3],
+ parent_mount: None,
+ vertex_start: 0,
+ vertex_count: parent_vertex_count,
+ }),
+ Some(DynamicComponentGeometry {
+ model: child_model,
+ root_transform: identity_transform,
+ owner_scale: [1.0; 3],
+ parent_mount: Some((0, 1)),
+ vertex_start: parent_vertex_count,
+ vertex_count: 3,
+ }),
+ ]];
+
+ // The fixture's resolved NDP row supplies the native initial value 1.0.
+ update_control_animation_pose_buffers(&mut instances, &mut poses, 0)
+ .expect("initial active timeline sample");
+ let first_root_pose = poses[0][0].as_ref().expect("first root pose").poses[0];
+ update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
+ .expect("initial geometry update");
+ let range_remap = reorder_draw_ranges(&mut mesh, &[2, 0, 1]).expect("environment reorder");
+ let mut dynamic_shadow_casters = dynamic_shadow_casters;
+ remap_dynamic_shadow_caster_draw_ranges(&mut dynamic_shadow_casters, &range_remap)
+ .expect("dynamic shadow draw-range remap");
+ refresh_dynamic_shadow_casters(
+ &mut shadow_casters,
+ &dynamic_shadow_casters,
+ &mesh,
+ &poses,
+ &mounts,
+ )
+ .expect("initial caster geometry refresh");
+ let encloses_triangles = |sphere: ShadowSphere, triangles: &[ShadowTriangle]| {
+ triangles
+ .iter()
+ .flat_map(|triangle| triangle.positions)
+ .all(|point| {
+ let offset = sub3(point, sphere.center);
+ let distance = offset[0].hypot(offset[1]).hypot(offset[2]);
+ distance.is_finite() && distance <= sphere.radius + 1.0e-4
+ })
+ };
+ let first_shadow_sphere = shadow_casters[0].sphere;
+ assert!(encloses_triangles(
+ first_shadow_sphere,
+ &shadow_casters[0].triangles
+ ));
+ assert_eq!(
+ dynamic_shadow_casters[0].component_draw_range_indices,
+ [1, 2]
+ );
+ assert_eq!(shadow_casters[0].triangles.len(), 2);
+ assert_eq!(
+ shadow_casters[0].triangles[0].positions[0],
+ mesh.vertices[0].position
+ );
+ assert_eq!(
+ shadow_casters[0].triangles[1].positions[0],
+ mesh.vertices[parent_vertex_count].position
+ );
+ let first_shadow_triangles = shadow_casters[0].triangles.clone();
+ let first_parent_vertex = mesh.vertices[0];
+ let first_child_vertex = mesh.vertices[parent_vertex_count];
+ let first_mount = mounts[0][1].expect("initial child mount");
+ let first_fx_anchor = control_type4_world_from_effect(
+ identity_matrix(),
+ [1.0; 3],
+ identity_transform,
+ Some(first_mount),
+ control_effect_owner_node_pose(poses[0][1].as_ref(), true, 0)
+ .expect("mounted FX owner pose"),
+ )
+ .expect("initial FX anchor");
+
+ update_control_animation_pose_buffers(&mut instances, &mut poses, 225)
+ .expect("later active timeline sample");
+ let later_root_pose = poses[0][0].as_ref().expect("later root pose").poses[0];
+ assert_ne!(first_root_pose, later_root_pose);
+ update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
+ .expect("later geometry update");
+ refresh_dynamic_shadow_casters(
+ &mut shadow_casters,
+ &dynamic_shadow_casters,
+ &mesh,
+ &poses,
+ &mounts,
+ )
+ .expect("later caster geometry refresh");
+ let later_shadow_sphere = shadow_casters[0].sphere;
+ let later_shadow_triangles = shadow_casters[0].triangles.clone();
+ assert!(encloses_triangles(
+ later_shadow_sphere,
+ &later_shadow_triangles
+ ));
+ assert_ne!(first_shadow_triangles, shadow_casters[0].triangles);
+ assert_eq!(
+ shadow_casters[0].triangles[0].positions[0],
+ mesh.vertices[0].position
+ );
+ let later_parent_vertex = mesh.vertices[0];
+ let later_child_vertex = mesh.vertices[parent_vertex_count];
+ let later_mount = mounts[0][1].expect("later child mount");
+ let later_fx_anchor = control_type4_world_from_effect(
+ identity_matrix(),
+ [1.0; 3],
+ identity_transform,
+ Some(later_mount),
+ control_effect_owner_node_pose(poses[0][1].as_ref(), true, 0)
+ .expect("mounted FX owner pose"),
)
- .expect_err("control/mesh node-count mismatch is rejected");
- assert!(mismatch.contains("does not match MSH node count"));
+ .expect("later FX anchor");
+
+ assert_ne!(first_parent_vertex.position, later_parent_vertex.position);
+ assert_ne!(first_parent_vertex.normal, later_parent_vertex.normal);
+ assert_ne!(first_mount.translation, later_mount.translation);
+ assert_eq!(first_child_vertex.position, first_mount.translation);
+ assert_eq!(later_child_vertex.position, later_mount.translation);
+ assert_ne!(first_fx_anchor[0][3], later_fx_anchor[0][3]);
+ assert_ne!(first_fx_anchor[1][3], later_fx_anchor[1][3]);
+
+ update_control_animation_pose_buffers(&mut instances, &mut poses, 225)
+ .expect("repeat the same timeline sample");
+ update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
+ .expect("repeat the same geometry update");
+ refresh_dynamic_shadow_casters(
+ &mut shadow_casters,
+ &dynamic_shadow_casters,
+ &mesh,
+ &poses,
+ &mounts,
+ )
+ .expect("repeat the same caster refresh");
+ assert_eq!(shadow_casters[0].sphere, later_shadow_sphere);
+ assert_eq!(shadow_casters[0].triangles, later_shadow_triangles);
+
+ update_control_animation_pose_buffers(&mut instances, &mut poses, 0)
+ .expect("return to the initial timeline sample");
+ update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
+ .expect("return to the initial geometry");
+ refresh_dynamic_shadow_casters(
+ &mut shadow_casters,
+ &dynamic_shadow_casters,
+ &mesh,
+ &poses,
+ &mounts,
+ )
+ .expect("rebuild the initial caster after a later pose");
+ assert_eq!(shadow_casters[0].sphere, first_shadow_sphere);
+ assert_eq!(shadow_casters[0].triangles, first_shadow_triangles);
}
#[test]
- fn exto_parent_control_updates_visible_child_mount_without_draw_geometry() {
- let mut model = control_pose_test_model();
- let mut socket_node = vec![u8::MAX; model.node_stride];
- socket_node[..2].copy_from_slice(&[0, 0]);
- socket_node[2..4].copy_from_slice(&0_u16.to_le_bytes());
- socket_node[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
- socket_node[6..8].copy_from_slice(&1_u16.to_le_bytes());
- model.nodes_raw.extend(socket_node);
- model.node_count = 2;
- let mut control = control_pose_test_control();
- control.model_node_count = 2;
- let unit_record = |immediate_child_count| fparkan_prototype::UnitComponentRecord {
+ fn visible_exto_parent_refreshes_child_mount_without_parent_geometry() {
+ let make_record = |kind, immediate_child_count| fparkan_prototype::UnitComponentRecord {
archive_raw: [0; 32],
resource_raw: [0; 32],
- kind: 0,
+ kind,
parent_or_link: 1,
description_raw: [0; 32],
tail0: 0,
immediate_child_count,
};
- let unit_tree = unit_component_tree(&[unit_record(1), unit_record(1), unit_record(0)])
- .expect("preorder Unit DAT tree");
- let controls = vec![None, Some(control.clone()), None];
- assert!(unit_component_is_visible(1, Some(PROTOTYPE_TYPE_EXTO)));
- assert!(unit_component_is_visible(2, Some(PROTOTYPE_TYPE_EXTO)));
- assert!(component_chain_has_tag21(Some(&unit_tree), &controls, 2));
- assert_eq!(unit_tree[1].parent_index, Some(0));
- assert_eq!(unit_tree[2].parent_index, Some(1));
- assert!(
- unit_tree[2]
- .parent_index
- .is_some_and(|parent_index| parent_index < 2),
- "Unit DAT preorder places a parent before its child"
- );
- let mut state = ControlInstanceState::default();
- let initial_hierarchy = static_preview_control_pose_buffer(&model, &control, 0, &mut state)
- .expect("initial EXTO parent pose")
- .expect("initial parent hierarchy");
- let later_hierarchy = static_preview_control_pose_buffer(&model, &control, 225, &mut state)
- .expect("animated EXTO parent pose")
- .expect("animated parent hierarchy");
- let mut pose_buffers = vec![vec![None, Some(initial_hierarchy), None]];
- let mounts = [DynamicComponentMount {
- object_index: 0,
- component_index: 2,
- parent_index: 1,
- socket: 1,
- }];
- let initial = node38_pose_from_hierarchy(
- pose_buffers[0][1].as_ref().expect("EXTO parent hierarchy"),
- 1,
- )
- .expect("parent socket");
- let mut mount_poses = vec![vec![None, None, Some(initial)]];
- pose_buffers[0][1] = Some(later_hierarchy);
- let parent_geometry = DynamicComponentGeometry {
- object_index: 0,
- component_index: 1,
- model: Arc::new(model.clone()),
- transform: LegacyIron3dEulerTransform {
- translation: [0.0; 3],
- orientation_radians: [0.0; 3],
- },
- scale: [1.0; 3],
- vertex_start: 0,
- vertex_count: 0,
- shadow_triangle_range: None,
+ let records = vec![
+ make_record(0, 1),
+ make_record(PROTOTYPE_TYPE_EXTO, 1),
+ make_record(PROTOTYPE_TYPE_EXTO, 0),
+ ];
+ let tree = unit_component_tree(&records).expect("valid three-record component tree");
+ assert_eq!(tree[2].parent_index, Some(1));
+ assert!(tree[1].parent_index.expect("parent has root") < 2);
+ assert!(unit_component_is_visible(1, Some(records[1].kind)));
+
+ let identity_transform = LegacyIron3dEulerTransform {
+ translation: [0.0; 3],
+ orientation_radians: [0.0; 3],
};
- let empty_parent_mesh = project_dynamic_component_geometry(
- &parent_geometry,
- pose_buffers[0][1].as_ref().expect("EXTO parent hierarchy"),
- None,
+ let mut parent_model = animated_socket_model();
+ parent_model.slots.clear();
+ parent_model.positions.clear();
+ parent_model.normals = None;
+ parent_model.uv0 = None;
+ parent_model.uv1 = None;
+ parent_model.indices.clear();
+ parent_model.batches.clear();
+ let initial_parent_pose =
+ node38_sampled_hierarchy_at_times(&parent_model, &[Some(AnimationTime(0.0)), None])
+ .expect("animated parent hierarchy without mesh geometry");
+ let parent_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
+ &parent_model,
+ identity_transform,
+ [1.0; 3],
+ &initial_parent_pose,
)
- .expect("zero-geometry EXTO parent");
- assert!(empty_parent_mesh.vertices.is_empty());
- assert!(empty_parent_mesh.indices.is_empty());
+ .expect("parent with no geometry projects successfully");
+ assert!(parent_mesh.vertices.is_empty());
+
+ let child_model = static_child_model();
+ let child_pose = node38_fallback_hierarchy(&child_model).expect("static child pose");
+ let initial_mount =
+ node38_pose_from_hierarchy(&initial_parent_pose, 1).expect("visible parent socket");
+ let mut mounts = vec![vec![None, None, Some(initial_mount)]];
+ let parent_mount = tree[2]
+ .parent_index
+ .filter(|parent_index| {
+ mounts[0][*parent_index].is_some()
+ || unit_component_is_visible(*parent_index, Some(records[*parent_index].kind))
+ })
+ .map(|parent_index| {
+ (
+ parent_index,
+ usize::try_from(records[2].parent_or_link).expect("valid EXTO parent socket"),
+ )
+ });
+ assert_eq!(parent_mount, Some((1, 1)));
- assert!(
- refresh_dynamic_component_mounts(&mounts, &pose_buffers, &mut mount_poses)
- .expect("refresh child mount")
- );
- assert_eq!(
- mount_poses[0][2].expect("child mount").translation,
- [0.0, 2.45, 0.0]
+ let child_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
+ &child_model,
+ identity_transform,
+ [1.0; 3],
+ &child_pose,
+ initial_mount,
+ )
+ .expect("initial child mesh");
+ let mut mesh = VulkanStaticMesh {
+ vertices: child_mesh.vertices,
+ indices: child_mesh.indices,
+ draw_ranges: Vec::new(),
+ };
+ let control = socket_timeline_control();
+ let controls = vec![None, Some(control.clone()), None];
+ assert!(control_tree_chain_has_tag21(2, Some(&tree), &controls));
+ let mut instances = vec![vec![
+ None,
+ Some(ControlAnimationInstance {
+ gate_values: initial_control_gate_values(&control),
+ control,
+ model: parent_model,
+ state: ControlInstanceState::default(),
+ last_sampled_node_times: Vec::new(),
+ reported_unsupported_fallback: false,
+ }),
+ None,
+ ]];
+ let mut poses = vec![vec![None, Some(initial_parent_pose), Some(child_pose)]];
+ let geometry = vec![vec![
+ None,
+ None,
+ Some(DynamicComponentGeometry {
+ model: child_model,
+ root_transform: identity_transform,
+ owner_scale: [1.0; 3],
+ parent_mount,
+ vertex_start: 0,
+ vertex_count: mesh.vertices.len(),
+ }),
+ ]];
+
+ update_control_animation_pose_buffers(&mut instances, &mut poses, 0)
+ .expect("initial visible parent timeline sample");
+ update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
+ .expect("initial child mount refresh");
+ let initial_child_vertex = mesh.vertices[0].position;
+ let initial_child_mount = mounts[0][2].expect("initial child mount");
+ assert_eq!(initial_child_vertex, initial_child_mount.translation);
+
+ update_control_animation_pose_buffers(&mut instances, &mut poses, 225)
+ .expect("later visible parent timeline sample");
+ update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
+ .expect("child mount refresh without parent geometry descriptor");
+ let later_child_mount = mounts[0][2].expect("later child mount");
+ assert_ne!(
+ initial_child_mount.translation,
+ later_child_mount.translation
);
- // The EXTO parent has a sampled Node38 hierarchy but no projected draw
- // vertices. Its hierarchy still drives the visible child's mount.
+ assert_eq!(mesh.vertices[0].position, later_child_mount.translation);
}
#[test]
- fn live_tick_and_pending_resize_keep_the_scene_sample_while_refreshing_projection() {
- let model = Arc::new(control_pose_drawable_test_model());
- let control = control_pose_test_control();
- let dynamic = DynamicComponentGeometry {
- object_index: 0,
- component_index: 0,
- model: Arc::clone(&model),
- transform: LegacyIron3dEulerTransform {
- translation: [0.0; 3],
- orientation_radians: [0.0; 3],
- },
- scale: [1.0; 3],
- vertex_start: 0,
- vertex_count: 3,
- shadow_triangle_range: Some(0..1),
- };
- let mut state = ControlInstanceState::default();
- let initial_pose = static_preview_control_pose_buffer(&model, &control, 0, &mut state)
- .expect("initial hierarchy")
- .expect("initial Node38 pose");
- let initial_mesh = project_dynamic_component_geometry(&dynamic, &initial_pose, None)
- .expect("initial projected component");
- let initial_triangles = static_mesh_shadow_triangles(&initial_mesh);
- let baseline = ShadowSphere {
+ fn dynamic_shadow_sphere_union_keeps_the_containing_input_sphere() {
+ let small = ShadowSphere {
center: [0.0; 3],
radius: 1.0,
};
- let mut land = fparkan_terrain_format::LandMeshDocument {
- streams: Vec::new(),
- nodes_raw: Vec::new(),
- slots: fparkan_terrain_format::TerrainSlotTable {
- header_raw: Vec::new(),
- slots_raw: Vec::new(),
- },
- positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
- normals: Vec::new(),
- uv0: Vec::new(),
- accelerator: Vec::new(),
- aux14: Vec::new(),
- aux18: Vec::new(),
- faces: vec![fparkan_terrain_format::TerrainFace28 {
- flags: FullSurfaceMask(1),
- material_tag: 0,
- aux_tag: 0,
- vertices: [0, 1, 2],
- neighbors: [None, None, None],
- tail_raw: [0; 8],
- raw: [0; 28],
- }],
- };
- set_test_terrain_lod0_range(&mut land);
- let terrain = Arc::new(TerrainWorld::from_land_msh(&land).expect("test terrain"));
- let free_camera = FreeFlightCamera {
- position: [0.25, 0.25, 0.0],
- yaw: 0.0,
- pitch: 0.0,
- horizontal_fov: 1.0,
- near_plane: 0.1,
- far_plane: 100.0,
- move_speed: 1.0,
- ground_clearance: 0.0,
+ let large = ShadowSphere {
+ center: [1.0, 0.0, 0.0],
+ radius: 10.0,
};
- let view_camera = free_camera.preview_frame(16.0 / 9.0);
- let preview = StaticPreviewScene {
- mesh: initial_mesh.clone(),
- native_handedness_by_range: HashMap::new(),
- materials: Vec::new(),
- material_animations: Vec::new(),
- sun_occlusion_range_indices: Vec::new(),
- shadow_casters: vec![ShadowCaster {
- id: 0,
- world_axes: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
- sphere: baseline,
- native_kind: 4,
- triangles: initial_triangles.clone(),
- }],
- shadow_sphere_baselines: vec![baseline],
- shadow_receivers: Vec::new(),
- shadow_material_index: 0,
- terrain,
- component_pose_buffers: vec![vec![Some(initial_pose)]],
- component_mount_poses: vec![vec![None]],
- control_animation_instances: vec![ControlAnimationInstance {
- object_index: 0,
- component_index: 0,
- model: Arc::clone(&model),
- control,
- state,
- }],
- dynamic_component_geometry: vec![dynamic],
- dynamic_component_mounts: Vec::new(),
- shadow_caster_indices_by_object: vec![Some(0)],
- camera: view_camera.vulkan_camera(),
- view_camera,
- captured_viewport: None,
- free_camera: None,
- camera_mode: "test",
- mesh_components: 1,
- terrain_components: 0,
- };
- let mut app = StaticVulkanApp::new(
- preview,
- None,
- None,
- EnvironmentGpuRange {
- range_index: usize::MAX,
- vertex_start: initial_mesh.vertices.len(),
- vertex_capacity: 0,
- material_index: 0,
- },
- initial_mesh.vertices.len(),
- initial_mesh.indices.len(),
- None,
- 0,
- None,
- "control-test",
- 1,
- None,
- false,
- StartupTimings::default(),
- );
- app.free_camera = Some(free_camera);
- app.pressed_keys.insert(KeyCode::KeyW);
- app.materials = packet_test_materials();
- app.material_animations = vec![random_material_binding(0)];
- let empty_range = app.shadow_range;
- app.environment_gpu = Some(EnvironmentGpuScene {
- material_animations: vec![random_material_binding(1)],
- sky_layers: Vec::new(),
- sprites: Vec::new(),
- rain: empty_range,
- snow: empty_range,
- lightning: empty_range,
- lightning_material_index: 0,
- });
- let start = app.last_tick;
- let sample = app.sample_live_frame_clock_at(start + Duration::from_millis(225));
- assert_eq!(sample.time_ms, 225);
- assert!((sample.delta_seconds - 0.225).abs() < f32::EPSILON);
- let mut update = app
- .prepare_scene_update(sample, [1280.0, 720.0])
- .expect("apply one full live tick to the scene");
- assert!(update.environment.is_none());
- assert!(update.environment_material_update.is_some());
- let world_material_packet = update.material_update.clone();
- let sky_material_packet = update.environment_material_update.clone();
- assert_eq!(app.material_animations[0].base.random_state, 2_531_011);
- assert_eq!(
- app.environment_gpu.as_ref().unwrap().material_animations[0]
- .base
- .random_state,
- 2_531_011
- );
- let expected_resized_camera = app
- .free_camera
- .expect("free-flight input controller")
- .preview_frame(4.0 / 3.0);
- let camera_after_tick = app.free_camera.expect("free-flight camera").position;
- assert!((camera_after_tick[0] - 0.35).abs() < 1.0e-5);
- assert!((camera_after_tick[1] - 0.25).abs() < 1.0e-5);
- assert_eq!(app.material_time_ms, sample.time_ms);
- let weather = WeatherInput {
- rain: WeatherState::new(true, 0.03, [0.7, 0.8, 1.0, 0.6]),
- snow: WeatherState::new(true, 0.03, [0.9, 0.95, 1.0, 0.8]),
- ..WeatherInput::clear()
- };
- let mut weather_system = EnvironmentSystem::new(0xC1_225);
- let first_environment_frame = weather_system.update(
- 0.225,
- weather,
- environment_camera_for_preview(&update.camera, update.aspect, update.viewport),
- );
- let mut precipitation_counts = [0usize; 2];
- for primitive in &first_environment_frame.primitives {
- if let EnvironmentPrimitive::Particle { kind, .. } = primitive {
- let slot = match kind {
- PrecipitationKind::Rain => 0,
- PrecipitationKind::Snow => 1,
- };
- precipitation_counts[slot] += 1;
- }
- }
- assert!(precipitation_counts.iter().all(|count| *count > 0));
- update.environment = Some(DynamicEnvironmentFrame {
- atmosphere: TypedAtmosphere {
- marker: 0,
- version: 0,
- tracks: Vec::new(),
- trailer: Default::default(),
- }
- .sample(0.225),
- sky_mesh: SkySystem::new(Default::default(), SkyMaterials::default())
- .expect("test sky topology")
- .mesh()
- .clone(),
- environment_frame: first_environment_frame.clone(),
- absolute_time_seconds: 0.225,
- });
- assert!(app.lifecycle.prepare(update.clone()));
- let control_state_after_tick = app.control_animation_instances[0].state.clone();
- assert_eq!(
- app.sample_live_frame_clock_at(start + Duration::from_millis(500)),
- sample
- );
- assert_eq!(app.simulation_elapsed, Duration::from_millis(225));
- assert_eq!(app.lifecycle.pending.as_ref(), Some(&update));
- assert_eq!(app.mesh.indices, initial_mesh.indices);
- assert_eq!(app.mesh.vertices.len(), initial_mesh.vertices.len());
- assert_ne!(
- initial_mesh.vertices[0].position,
- app.mesh.vertices[0].position
- );
- assert!((app.mesh.vertices[0].position[1] - 0.45).abs() < 1.0e-4);
- let b_vertices = app.mesh.vertices.clone();
- let b_triangles = app.shadow_casters[0].triangles.clone();
- let b_sphere = app.shadow_casters[0].sphere;
- assert_ne!(b_triangles, initial_triangles);
- assert!(b_sphere.radius > baseline.radius);
-
- app.free_camera.as_mut().unwrap().look_delta(64.0, -24.0);
- let controller_after_input = app.free_camera.unwrap();
- app.refresh_pending_viewport([800.0, 600.0])
- .expect("refresh only viewport-dependent state after recreation");
- let refreshed = app
- .lifecycle
- .pending
- .as_ref()
- .expect("pending sample")
- .clone();
- assert_eq!(refreshed.sample, sample);
- assert_eq!(refreshed.material_update, world_material_packet);
- assert_eq!(refreshed.environment_material_update, sky_material_packet);
- assert_eq!(app.material_animations[0].base.random_state, 2_531_011);
assert_eq!(
- app.environment_gpu.as_ref().unwrap().material_animations[0]
- .base
- .random_state,
- 2_531_011
- );
- app.refresh_pending_viewport([1024.0, 768.0])
- .expect("refresh the same pending sample a second time");
- let repeated = app.lifecycle.pending.as_ref().expect("pending sample");
- assert_eq!(repeated.sample, sample);
- assert_eq!(repeated.material_update, world_material_packet);
- assert_eq!(repeated.environment_material_update, sky_material_packet);
- assert_eq!(repeated.viewport, [1024.0, 768.0]);
- assert_eq!(repeated.aspect, 4.0 / 3.0);
- assert_eq!(app.material_animations[0].base.random_state, 2_531_011);
- assert_eq!(
- app.environment_gpu.as_ref().unwrap().material_animations[0]
- .base
- .random_state,
- 2_531_011
- );
- assert_eq!(refreshed.viewport, [800.0, 600.0]);
- assert_eq!(refreshed.aspect, 4.0 / 3.0);
- assert_eq!(refreshed.camera.position, update.camera.position);
- assert_eq!(refreshed.camera.forward, update.camera.forward);
- assert_eq!(refreshed.camera.right, update.camera.right);
- assert_eq!(refreshed.camera.up, update.camera.up);
- assert_eq!(refreshed.camera.position, expected_resized_camera.position);
- assert_eq!(refreshed.camera.forward, expected_resized_camera.forward);
- assert_eq!(refreshed.camera.right, expected_resized_camera.right);
- assert_eq!(refreshed.camera.up, expected_resized_camera.up);
- assert!(
- (refreshed.camera.vertical_fov - expected_resized_camera.vertical_fov).abs() < 1.0e-6
+ shadow_sphere_enclosing_current_geometry(small, large, &[])
+ .expect("large current native sphere contains baseline"),
+ large
);
- assert!(refreshed
- .camera
- .clip_from_world
- .iter()
- .zip(expected_resized_camera.clip_from_world)
- .all(|(actual, expected)| (actual - expected).abs() < 1.0e-5));
- assert_ne!(
- refreshed.camera.clip_from_world,
- update.camera.clip_from_world
- );
- assert_eq!(app.free_camera, Some(controller_after_input));
- let refreshed_environment_frame =
- &refreshed.environment.as_ref().unwrap().environment_frame;
assert_eq!(
- refreshed_environment_frame.point_lights,
- first_environment_frame.point_lights
- );
- assert_eq!(
- refreshed_environment_frame.sounds,
- first_environment_frame.sounds
+ shadow_sphere_enclosing_current_geometry(large, small, &[])
+ .expect("large baseline sphere contains current native sphere"),
+ large
);
- let mut reprojected_counts = [0usize; 2];
- for (sampled, refreshed) in first_environment_frame
- .primitives
- .iter()
- .zip(&refreshed_environment_frame.primitives)
- {
- let (
- EnvironmentPrimitive::Particle {
- kind: sampled_kind,
- position: sampled_position,
- velocity: sampled_velocity,
- size: sampled_size,
- screen: sampled_screen,
- ..
- },
- EnvironmentPrimitive::Particle {
- kind,
- position,
- velocity,
- size,
- screen,
- ..
- },
- ) = (sampled, refreshed)
- else {
- panic!("rain and snow fixture contains only precipitation particles");
- };
- assert_eq!(kind, sampled_kind);
- assert_eq!(position, sampled_position);
- assert_eq!(velocity, sampled_velocity);
- assert_ne!(screen, sampled_screen);
- assert_ne!(size, sampled_size);
- let slot = match kind {
- PrecipitationKind::Rain => 0,
- PrecipitationKind::Snow => 1,
- };
- reprojected_counts[slot] += 1;
+ }
+
+ fn endpoint_op3_command() -> FxCommand {
+ let mut body = vec![0; 0xc4];
+ write_effect_u32(&mut body, 0, 2);
+ write_effect_f32(&mut body, 0x04, 0.0);
+ write_effect_f32(&mut body, 0x08, 1.0);
+ write_effect_f32(&mut body, 0x0c, 1.0);
+ write_effect_f32(&mut body, 0x10, 0.0);
+ write_effect_f32(&mut body, 0x14, 1.0);
+ write_effect_f32(&mut body, 0x18, 1.0);
+ write_effect_f32(&mut body, 0x1c, 0.0);
+ write_effect_f32(&mut body, 0x20, 1.0);
+ write_effect_vec3(&mut body, 0x24, [0.0; 3]);
+ write_effect_vec3(&mut body, 0x30, [0.0; 3]);
+ write_effect_vec3(&mut body, 0x3c, [1.0; 3]);
+ write_effect_vec3(&mut body, 0x48, [0.0, 0.0, 1.0]);
+ write_effect_vec3(&mut body, 0x54, [0.0, 0.0, 1.0]);
+ write_effect_vec3(&mut body, 0x60, [1.0; 3]);
+ write_effect_vec3(&mut body, 0x6c, [1.0; 3]);
+ write_effect_vec3(&mut body, 0x78, [1.0; 3]);
+ body[0x84..0x84 + 11].copy_from_slice(b"effects.rlb");
+ let name = b"f_smalllight_y";
+ body[0xa4..0xa4 + name.len()].copy_from_slice(name);
+ FxCommand {
+ word: 3,
+ opcode: FxOpcode::Op3,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
}
- assert!(reprojected_counts.iter().all(|count| *count > 0));
- assert_eq!(
- app.apply_pending_scene_update().unwrap_err(),
- "native Vulkan renderer was not initialized"
- );
- let restored = app
- .lifecycle
- .pending
- .as_ref()
- .expect("restored pending sample");
- assert_eq!(restored.sample, sample);
- assert_eq!(restored.material_update, world_material_packet);
- assert_eq!(restored.environment_material_update, sky_material_packet);
- assert_eq!(app.material_animations[0].base.random_state, 2_531_011);
- assert_eq!(
- app.environment_gpu.as_ref().unwrap().material_animations[0]
- .base
- .random_state,
- 2_531_011
- );
- assert_eq!(app.mesh.vertices, b_vertices);
- assert_eq!(app.shadow_casters[0].triangles, b_triangles);
- assert_eq!(
- app.control_animation_instances[0].state,
- control_state_after_tick
- );
- assert_eq!(app.material_time_ms, sample.time_ms);
- assert_eq!(app.simulation_elapsed, Duration::from_millis(225));
- assert_eq!(app.lifecycle.frames_presented, 0);
- assert_eq!(app.lifecycle.record_presented(), Some(0));
+ }
- app.update_controlled_component_geometry(sample)
- .expect("repeat the same control timestamp");
- assert_eq!(app.mesh.vertices, b_vertices);
- assert_eq!(app.shadow_casters[0].triangles, b_triangles);
- assert_eq!(app.shadow_casters[0].sphere, b_sphere);
+ fn endpoint_op1_command(effect_type: u32, start: f32, end: f32) -> FxCommand {
+ let mut body = vec![0; 132];
+ write_effect_u32(&mut body, 0, effect_type);
+ write_effect_f32(&mut body, 4, start);
+ write_effect_f32(&mut body, 8, end);
+ write_effect_vec3(&mut body, 12, [1.0, 0.0, 0.0]);
+ write_effect_vec3(&mut body, 24, [3.0, 0.0, 0.0]);
+ write_effect_vec3(&mut body, 36, [0.0, 1.0, 0.0]);
+ write_effect_vec3(&mut body, 48, [0.0, 1.0, 0.0]);
+ for (index, value) in [0.0, 1.0, 0.5, 1.0].into_iter().enumerate() {
+ write_effect_f32(&mut body, 60 + index * 4, value);
+ }
+ for (index, value) in [0.5, 0.0, 1.0, 1.0].into_iter().enumerate() {
+ write_effect_f32(&mut body, 76 + index * 4, value);
+ }
+ write_effect_f32(&mut body, 108, 10.0);
+ write_effect_f32(&mut body, 112, 20.0);
+ write_effect_f32(&mut body, 116, 1.0);
+ write_effect_vec3(&mut body, 120, [1.0, 0.5, 0.25]);
+ FxCommand {
+ word: 1,
+ opcode: FxOpcode::Op1,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
+ }
+ }
- let first_sample = FrameClockSample {
- id: SceneSampleId::Live(0),
- time_ms: 0,
- delta_seconds: 0.0,
+ fn endpoint_gener_ball_command(opcode: FxOpcode) -> FxCommand {
+ let is_op9 = opcode == FxOpcode::Op9;
+ let mut body = vec![0; if is_op9 { 0xcc } else { 0xc8 }];
+ write_effect_u32(&mut body, 0, if is_op9 { 2 } else { 0 });
+ write_effect_f32(&mut body, 0x04, 0.0);
+ write_effect_f32(&mut body, 0x08, 1.0);
+ write_effect_f32(&mut body, 0x0c, 1.0);
+ write_effect_f32(&mut body, 0x10, 0.0);
+ write_effect_f32(&mut body, 0x14, 1.0);
+ write_effect_f32(&mut body, 0x18, 1.0);
+ write_effect_f32(&mut body, 0x1c, 0.75);
+ write_effect_f32(&mut body, 0x20, 1.0);
+ write_effect_vec3(&mut body, 0x24, [0.0; 3]);
+ write_effect_vec3(&mut body, 0x30, [0.0; 3]);
+ write_effect_vec3(&mut body, 0x3c, [1.0; 3]);
+ write_effect_vec3(&mut body, 0x48, [0.0, 1.0, 0.0]);
+ write_effect_vec3(&mut body, 0x54, [0.0, 1.0, 0.0]);
+ write_effect_vec3(&mut body, 0x60, [1.0; 3]);
+ write_effect_vec3(&mut body, 0x6c, [1.0; 3]);
+ write_effect_vec3(&mut body, 0x78, [1.0; 3]);
+ body[0x84..0x84 + 12].copy_from_slice(b"material.lib");
+ let name = if is_op9 {
+ b"laser_y_hit".as_slice()
+ } else {
+ b"glow_y"
};
- app.update_material_clock(first_sample);
- app.update_controlled_component_geometry(first_sample)
- .expect("return to first control pose");
- assert_eq!(app.mesh.vertices, initial_mesh.vertices);
- assert_eq!(app.shadow_casters[0].triangles, initial_triangles);
- assert_eq!(app.shadow_casters[0].sphere, baseline);
-
- let next_sample = app.sample_live_frame_clock_at(start + Duration::from_millis(241));
- assert_eq!(next_sample.id, SceneSampleId::Live(1));
- assert_eq!(next_sample.time_ms, 241);
- let next_update = app
- .prepare_scene_update(next_sample, [800.0, 600.0])
- .expect("the next sampled frame adopts mouse input received during retry");
- assert_ne!(next_update.camera.forward, update.camera.forward);
- assert_eq!(next_update.camera.forward, controller_after_input.forward());
+ body[0xa4..0xa4 + name.len()].copy_from_slice(name);
+ if is_op9 {
+ write_effect_u32(&mut body, 0xc4, 0);
+ } else {
+ write_effect_f32(&mut body, 0xc4, 500.0);
+ }
+ FxCommand {
+ word: if is_op9 { 9 } else { 4 },
+ opcode,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
+ }
}
- fn set_test_terrain_lod0_range(terrain: &mut fparkan_terrain_format::LandMeshDocument) {
- const NODE_STRIDE: usize = 38;
- const SLOT_STRIDE: usize = 68;
- let mut node = vec![u8::MAX; NODE_STRIDE];
- node[8..10].copy_from_slice(&0_u16.to_le_bytes());
- node[10..12].copy_from_slice(&u16::MAX.to_le_bytes());
- let mut slot = [0_u8; SLOT_STRIDE];
- slot[..2].copy_from_slice(&0_u16.to_le_bytes());
- let face_count = u16::try_from(terrain.faces.len()).expect("test face count fits");
- slot[2..4].copy_from_slice(&face_count.to_le_bytes());
- terrain.nodes_raw = node;
- terrain.slots.slots_raw = vec![slot];
+ fn endpoint_op8_command(pool_count: u32) -> FxCommand {
+ let mut body = vec![0; 0xf4];
+ write_effect_u32(&mut body, 0x20, pool_count);
+ FxCommand {
+ word: 8,
+ opcode: FxOpcode::Op8,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
+ }
}
- fn strings(values: &[&str]) -> Vec<String> {
- values.iter().map(|value| (*value).to_string()).collect()
+ fn endpoint_op7_command(
+ mode: u32,
+ material_phase_factor: f32,
+ acceleration: f32,
+ pool_count: u32,
+ copies_per_group: u32,
+ ) -> FxCommand {
+ let mut body = vec![0; 0xCC];
+ write_effect_u32(&mut body, 0x00, mode);
+ write_effect_f32(&mut body, 0x1C, material_phase_factor);
+ write_effect_u32(&mut body, 0x20, pool_count);
+ write_effect_u32(&mut body, 0x24, copies_per_group);
+ write_effect_f32(&mut body, 0x88, acceleration);
+ FxCommand {
+ word: 7,
+ opcode: FxOpcode::Op7,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
+ }
}
- fn scene_frame_update(sample: FrameClockSample, viewport: [f32; 2]) -> SceneFrameUpdate {
- let aspect = viewport[0] / viewport[1];
- let camera = FreeFlightCamera {
- position: [0.0; 3],
- yaw: 0.0,
- pitch: 0.0,
- horizontal_fov: 1.0,
- near_plane: 0.1,
- far_plane: 100.0,
- move_speed: 1.0,
- ground_clearance: 0.0,
- }
- .preview_frame(aspect);
- SceneFrameUpdate {
- sample,
- camera,
- viewport,
- aspect,
- material_update: MaterialFrameUpdate::default(),
- environment_material_update: None,
- environment: None,
+ fn endpoint_effect_frame(command_index: usize) -> fparkan_fx::fx_visual::EffectQuadFrame {
+ fparkan_fx::fx_visual::EffectQuadFrame {
+ command_index,
+ opcode: FxOpcode::Op7,
+ material: fparkan_fx::environment::EffectResource {
+ archive: "material.lib".to_owned(),
+ name: "smoke_yg".to_owned(),
+ },
+ vertices: [[0.0; 3]; 4],
+ uv: [[0.0; 2]; 4],
+ color: [1.0; 4],
+ material_phase: 0.25,
+ elapsed_seconds: 0.5,
+ normalized_time: 0.5,
+ active: true,
+ native_flag_base: None,
}
}
+ fn strings(values: &[&str]) -> Vec<String> {
+ values.iter().map(|value| (*value).to_string()).collect()
+ }
+
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_part1_prepares_every_mission() {
@@ -8177,9 +13066,12 @@ mod tests {
};
let mut bindings = vec![MaterialAnimationBinding {
initial_phase_index: 0,
+ normalized_phase_override: None,
+ ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices: vec![10],
range_indices: vec![0, 2],
+ pipeline_category: 0,
}],
base: empty_binding(),
detail: None,
@@ -8236,10 +13128,42 @@ mod tests {
}
#[test]
+ fn cpu_msh_portal_alpha_updates_with_distance_and_preserves_type16() {
+ let mut mesh = VulkanStaticMesh::smoke_triangle();
+ for flags in [0x0148, 0x0108, 0x0118, 0x0058] {
+ mesh.draw_ranges[0].batch_flags = flags;
+ assert_eq!(
+ native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -1.0], 1.04),
+ Ok(Some(0.0))
+ );
+ assert_eq!(
+ native_msh_diffuse_alpha_override(&mesh, 0, true, [0.0, -0.55, -1.0], 1.04),
+ Ok(None)
+ );
+ }
+ mesh.draw_ranges[0].batch_flags = 0x0118;
+ let alpha = native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -25.0], 1.04)
+ .unwrap()
+ .unwrap();
+ assert!((alpha - 0.5).abs() < 0.000_001);
+ assert_eq!(
+ native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -60.0], 1.04),
+ Ok(Some(1.0))
+ );
+ assert!(native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0; 3], 0.0).is_err());
+ mesh.draw_ranges[0].batch_flags = 0x0040;
+ assert_eq!(
+ native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0; 3], 1.04),
+ Ok(None)
+ );
+ }
+
+ #[test]
fn material_animation_selects_pipeline_category_with_active_phase() {
let variant = MaterialVariantSet {
phase_material_indices: vec![10, 11],
range_indices: vec![3],
+ pipeline_category: 1,
};
let category_for = |material_index| match material_index {
10 => Some(1),
@@ -8314,36 +13238,33 @@ mod tests {
}
#[test]
- fn animated_shadow_bounds_rebuild_from_native_baseline() {
- let baseline = ShadowSphere {
- center: [0.0; 3],
- radius: 1.0,
- };
- let pose_a = [shadow_triangle(
- [[0.5, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.5]],
- 0,
- )];
- let pose_b = [shadow_triangle(
- [[3.0, 0.0, 0.0], [4.0, 0.0, 0.0], [3.0, 1.0, 0.0]],
- 0,
- )];
-
- let first_a =
- shadow_sphere_enclosing_triangles(baseline, &pose_a).expect("initial animated bounds");
- assert_eq!(first_a, baseline);
- let at_b =
- shadow_sphere_enclosing_triangles(baseline, &pose_b).expect("expanded animated bounds");
- assert!((at_b.center[0] - 1.5).abs() < 1.0e-5);
- assert!((at_b.radius - 2.5).abs() < 1.0e-5);
- assert!(length3(at_b.center).expect("finite sphere center") + 1.0 <= at_b.radius);
- for point in pose_b.iter().flat_map(|triangle| triangle.positions) {
- assert!(
- length3(sub3(point, at_b.center)).expect("finite point distance") <= at_b.radius
- );
- }
- let second_a = shadow_sphere_enclosing_triangles(baseline, &pose_a)
- .expect("return to first animated pose");
- assert_eq!(second_a, first_a);
+ fn projected_shadow_alone_uses_decal_depth_bias() {
+ let shadow = projected_shadow_pipeline_state();
+ assert_eq!(shadow.blend, LegacyBlendMode::SourceAlpha);
+ assert_eq!(shadow.depth, LegacyDepthMode::TestReadOnly);
+ assert_eq!(shadow.depth_bias, LegacyDepthBias::ProjectedDecal);
+ assert_eq!(
+ world_pipeline_state(LegacyBlendMode::Opaque, LegacyDepthMode::TestWrite).depth_bias,
+ LegacyDepthBias::Disabled
+ );
+ }
+
+ #[test]
+ fn shadow_atlas_texture_equality_includes_pixels_and_mip_layout() {
+ let uploaded = shadow_atlas_texture();
+ assert_eq!(shadow_atlas_texture(), uploaded);
+
+ let mut changed_rgba = uploaded.clone();
+ changed_rgba.rgba8[0] ^= 1;
+ assert_ne!(changed_rgba, uploaded);
+
+ let mut changed_mip_pixels = uploaded.clone();
+ changed_mip_pixels.mip_levels[0].rgba8[0] ^= 1;
+ assert_ne!(changed_mip_pixels, uploaded);
+
+ let mut changed_mip = uploaded.clone();
+ changed_mip.mip_levels[0].width -= 1;
+ assert_ne!(changed_mip, uploaded);
}
#[test]
@@ -8416,6 +13337,7 @@ mod tests {
control_clock_ms: None,
validation: false,
readback_out: None,
+ readback_sequence_out: None,
preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None,
static_animation_frame: None,
@@ -8440,44 +13362,810 @@ mod tests {
}
#[test]
- fn replay_clock_stays_on_one_sample_until_presented() {
- let mut lifecycle = SceneFrameLifecycle::default();
- let first = lifecycle.sample_replay(500, 101);
- assert_eq!(first.id, SceneSampleId::Replay(0));
- assert_eq!(first.time_ms, 0);
- assert_eq!(first.delta_seconds, 0.0);
- let update = scene_frame_update(first, [1280.0, 720.0]);
- assert!(lifecycle.prepare(update.clone()));
-
- // A swapchain recreation retries the same packet and does not count a frame.
- assert_eq!(lifecycle.sample_replay(500, 101), first);
- assert_eq!(lifecycle.frames_presented, 0);
- assert_eq!(lifecycle.pending.as_ref(), Some(&update));
- assert_eq!(lifecycle.record_presented(), Some(0));
-
- let second = lifecycle.sample_replay(500, 101);
- assert_eq!(second.id, SceneSampleId::Replay(1));
- assert_eq!(second.time_ms, 5);
- assert!((second.delta_seconds - 0.005).abs() < f32::EPSILON);
+ fn control_clock_replays_one_shared_clock_from_zero_to_target() {
+ assert_eq!(control_clock_ms_for_frame(500, 0, 101), 0);
+ assert_eq!(control_clock_ms_for_frame(500, 50, 101), 250);
+ assert_eq!(control_clock_ms_for_frame(500, 100, 101), 500);
+ assert_eq!(control_clock_ms_for_frame(500, 1000, 101), 500);
+ assert_eq!(control_clock_ms_for_frame(500, 0, 1), 500);
+ assert_eq!(control_clock_mode_label(Some(0)), "fixed-0ms");
+ assert_eq!(control_clock_mode_label(Some(500)), "ramp-0-to-500ms");
+ assert_eq!(control_clock_mode_label(None), "realtime");
+ }
+
+ fn clock_test_app(
+ control_clock_target_ms: Option<u32>,
+ ) -> (StaticVulkanApp, ControlEffectInstanceKey) {
+ let phase = |additive_red| MaterialPhase {
+ parameters: [
+ additive_red,
+ 0,
+ 0,
+ 100,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ ],
+ texture_raw: [0; 16],
+ };
+ let document = Mat0Document {
+ version: 0,
+ animation_block_count: 1,
+ phases: vec![phase(0), phase(255)],
+ prefix: Vec::new(),
+ header_opaque: [0; 2],
+ animation_blocks: vec![MaterialAnimationBlock {
+ header_raw: 0,
+ keys: vec![
+ MaterialKey {
+ k0: 0,
+ k1: 500,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 1,
+ k1: 1000,
+ k2: 0,
+ },
+ ],
+ bytes: Vec::new(),
+ }],
+ };
+ let material_animation = MaterialAnimationBinding {
+ initial_phase_index: 0,
+ normalized_phase_override: None,
+ ambient_fx_material_alpha: false,
+ variants: vec![MaterialVariantSet {
+ phase_material_indices: vec![0, 1],
+ range_indices: vec![0],
+ pipeline_category: 0,
+ }],
+ base: MaterialPhaseBinding {
+ document,
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: 0,
+ frozen_phase_index: None,
+ phase_uv_transforms: vec![[0.0, 0.0, 1.0, 1.0]; 2],
+ },
+ detail: None,
+ overlay: None,
+ overlay_detail: None,
+ stage_textures: None,
+ };
+ let mut materials = Vec::new();
+ append_environment_gradient_material(&mut materials)
+ .expect("first animated material variant");
+ append_environment_gradient_material(&mut materials)
+ .expect("second animated material variant");
+
+ let key = ControlEffectInstanceKey {
+ object_index: 0,
+ component_index: 0,
+ table_index: 0,
+ row_index: 0,
+ };
+ let control_slot = ControlPointLightSlot {
+ time_mode: 1,
+ header_flags: 9,
+ random_instance_key: key,
+ random_seed: 0x1234_5678,
+ phase_jitter: 0.25,
+ random_shift: [2.0, 3.0, 4.0],
+ duration_seconds: 1.0,
+ owner_object_index: 0,
+ owner_component_index: 0,
+ owner_node_index: 0,
+ owner_component_is_mounted: false,
+ root_transform: LegacyIron3dEulerTransform {
+ translation: [10.0, 20.0, 30.0],
+ orientation_radians: [0.0; 3],
+ },
+ owner_scale: [1.0; 3],
+ mount_pose: None,
+ local_from_effect: [
+ [1.0, 0.0, 0.0, 1.0],
+ [0.0, 1.0, 0.0, 2.0],
+ [0.0, 0.0, 1.0, 3.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ],
+ world_from_effect: [[0.0; 4]; 4],
+ command_index: 0,
+ command: endpoint_op1_command(6, 0.5, 1.0),
+ };
+
+ let sky_materials = SkyMaterials::default();
+ let sky =
+ SkySystem::new(Default::default(), sky_materials.clone()).expect("test sky topology");
+ let environment = DynamicEnvironment {
+ schedule: TypedAtmosphere {
+ marker: 0,
+ version: 0,
+ tracks: Vec::new(),
+ trailer: Default::default(),
+ },
+ sky,
+ environment: EnvironmentSystem::new(0x4650_4152_4B41_4E),
+ materials: sky_materials,
+ material_assets: HashMap::new(),
+ schedule_offset_seconds: 0.0,
+ time_seconds: 0.0,
+ fixed_schedule_seconds: None,
+ fixed_phase_reported: false,
+ };
+
+ let mut land = fparkan_terrain_format::LandMeshDocument {
+ streams: Vec::new(),
+ nodes_raw: Vec::new(),
+ slots: fparkan_terrain_format::TerrainSlotTable {
+ header_raw: Vec::new(),
+ slots_raw: Vec::new(),
+ },
+ positions: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]],
+ normals: Vec::new(),
+ uv0: Vec::new(),
+ accelerator: Vec::new(),
+ aux14: Vec::new(),
+ aux18: Vec::new(),
+ faces: vec![fparkan_terrain_format::TerrainFace28 {
+ flags: FullSurfaceMask(1),
+ material_tag: 0,
+ aux_tag: 0,
+ vertices: [0, 1, 2],
+ neighbors: [None, None, None],
+ tail_raw: [0; 8],
+ raw: [0; 28],
+ }],
+ };
+ set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
+ let terrain =
+ Arc::new(TerrainWorld::from_land_msh(&land).expect("clock test LOD0 terrain"));
+ let free_camera = FreeFlightCamera {
+ position: [2.0, 2.0, 5.0],
+ yaw: 0.25,
+ pitch: -0.1,
+ horizontal_fov: 1.3,
+ near_plane: 0.5,
+ far_plane: 700.0,
+ move_speed: 4.0,
+ ground_clearance: 0.0,
+ };
+ let view_camera = free_camera.preview_frame(16.0 / 9.0);
+ let preview = StaticPreviewScene {
+ mesh: VulkanStaticMesh {
+ vertices: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
+ .into_iter()
+ .map(|position| VulkanStaticVertex {
+ position,
+ color: [1.0; 3],
+ normal: [0.0, 0.0, 1.0],
+ uv: [0.0; 2],
+ detail_uv: [0.0; 2],
+ overlay_alpha: 0.0,
+ })
+ .collect(),
+ indices: vec![0, 1, 2],
+ draw_ranges: vec![VulkanStaticDrawRange {
+ first_index: 0,
+ index_count: 3,
+ material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ terrain_base_only: false,
+ pipeline_state: world_pipeline_state(
+ LegacyBlendMode::Opaque,
+ LegacyDepthMode::TestReadOnly,
+ ),
+ alpha_test_reference: 0,
+ }],
+ },
+ native_handedness_by_range: HashMap::new(),
+ type16_present_ranges: HashSet::new(),
+ materials,
+ material_animations: vec![material_animation],
+ ambient_effect_slots: Vec::new(),
+ control_point_light_slots: vec![control_slot],
+ component_pose_buffers: vec![vec![None]],
+ component_mount_poses: vec![vec![None]],
+ dynamic_component_geometry: Vec::new(),
+ building_collision_bindings: Vec::new(),
+ control_animation_instances: Vec::new(),
+ dynamic_shadow_casters: Vec::new(),
+ sun_occlusion_range_indices: Vec::new(),
+ shadow_casters: Vec::new(),
+ shadow_receivers: Vec::new(),
+ shadow_material_index: 0,
+ terrain: terrain.clone(),
+ camera: view_camera.vulkan_camera(),
+ view_camera,
+ captured_viewport: None,
+ free_camera: Some(free_camera),
+ camera_mode: "test",
+ mesh_components: 0,
+ terrain_components: 0,
+ };
+ let app = StaticVulkanApp::new(
+ preview,
+ Some(environment),
+ None,
+ EnvironmentGpuRange {
+ range_index: 1,
+ vertex_start: 3,
+ vertex_capacity: 0,
+ material_index: 0,
+ },
+ 3,
+ 3,
+ None,
+ 2,
+ "clock-test",
+ 1,
+ None,
+ None,
+ false,
+ control_clock_target_ms,
+ StartupTimings::default(),
+ );
+ (app, key)
+ }
+
+ fn control_random_snapshot(
+ state: &ControlEffectRandomState,
+ ) -> (
+ u32,
+ u32,
+ ControlEffectRandomSample,
+ Option<SceneSampleId>,
+ Option<u32>,
+ ) {
+ (
+ state.phase_and_z_rng.state,
+ state.xy_rng.state,
+ state.sample,
+ state.last_update_id,
+ state.last_update_clock_ms,
+ )
+ }
+
+ #[test]
+ fn live_clock_samples_have_increasing_ids_and_monotonic_time() {
+ let (mut app, _) = clock_test_app(None);
+ let first = app.sample_frame_clock();
+ let second = app.sample_frame_clock();
+ assert_eq!(first.id, SceneSampleId::Live(0));
+ assert_eq!(second.id, SceneSampleId::Live(1));
+ assert!(second.time_ms >= first.time_ms);
+ assert!(second.delta_seconds >= 0.0);
}
#[test]
- fn replay_clock_requires_at_least_two_requested_frames() {
- for frame_count in ["0", "1"] {
- assert!(Args::parse(&strings(&[
- "--root",
- "testdata/IS",
- "--mission",
- "MISSIONS/Autodemo.00/data.tma",
- "--frames",
- frame_count,
- "--control-clock-ms",
- "500",
- ]))
- .unwrap_err()
- .contains("--frames of at least 2"));
+ fn zero_extent_resume_does_not_add_minimized_time_to_live_clock() {
+ let (mut app, _) = clock_test_app(None);
+ let started = Instant::now();
+ app.simulation_last_tick = started;
+ app.pause_live_clock_at(started);
+ assert!(app.live_clock_paused_for_zero_extent);
+
+ let restored = started + Duration::from_secs(600);
+ app.resume_live_clock_at(restored);
+ assert!(!app.live_clock_paused_for_zero_extent);
+ assert_eq!(app.simulation_last_tick, restored);
+
+ let resumed = app.sample_live_frame_clock_at(restored + Duration::from_millis(16));
+ assert_eq!(resumed.id, SceneSampleId::Live(0));
+ assert_eq!(resumed.time_ms, 16);
+ assert!((resumed.delta_seconds - 0.016).abs() < 1.0e-6);
+ assert_eq!(app.simulation_elapsed, Duration::from_millis(16));
+ }
+
+ #[test]
+ fn live_cpu_updates_sample_materials_effect_rng_and_environment_without_renderer() {
+ let (mut app, key) = clock_test_app(None);
+ app.render_scene_update()
+ .expect("an early redraw without a packet is a no-op");
+ assert!(app.pending_scene_update.is_none());
+ assert_eq!(app.frames_presented, 0);
+ assert_eq!(app.simulation_update_generation, 0);
+
+ let mut first = app.sample_frame_clock();
+ assert_eq!(first.id, SceneSampleId::Live(0));
+ first.time_ms = 0;
+ first.delta_seconds = 0.1;
+ app.prepare_scene_update(first, [1280.0, 720.0])
+ .expect("prepare a live CPU frame without a renderer");
+ assert!(app.renderer.is_none());
+ let first_frame = app.pending_scene_update.as_ref().expect("sampled frame");
+ assert_eq!(
+ first_frame.material_update.active_materials[&0].phase_index,
+ 0
+ );
+ assert!(!first_frame
+ .environment
+ .as_ref()
+ .unwrap()
+ .sky_mesh
+ .vertices
+ .is_empty());
+ let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
+ assert_eq!(first_random.3, Some(SceneSampleId::Live(0)));
+ assert_ne!(first_random.0, 0);
+ assert_ne!(first_random.1, 0);
+ let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
+ assert!(first_environment_time > 0.0);
+ let first_vertices = app.mesh.vertices.clone();
+
+ for _ in 0..2 {
+ app.render_scene_update()
+ .expect("renderer-free redraw does not sample simulation state");
}
+ assert_eq!(
+ control_random_snapshot(&app.control_effect_random_states[&key]),
+ first_random
+ );
+ assert_eq!(
+ app.environment.as_ref().unwrap().time_seconds,
+ first_environment_time
+ );
+ assert_eq!(app.mesh.vertices, first_vertices);
+ assert_eq!(app.simulation_update_generation, 1);
+
+ let mut next = app.sample_frame_clock();
+ assert_eq!(next.id, SceneSampleId::Live(1));
+ next.time_ms = 500;
+ next.delta_seconds = 0.5;
+ app.prepare_scene_update(next, [1280.0, 720.0])
+ .expect("replace the latest live packet");
+ assert_eq!(app.simulation_update_generation, 2);
+ assert_eq!(
+ app.pending_scene_update.as_ref().unwrap().clock.id,
+ SceneSampleId::Live(1)
+ );
+ assert_ne!(
+ control_random_snapshot(&app.control_effect_random_states[&key]),
+ first_random
+ );
+ assert!(app.environment.as_ref().unwrap().time_seconds > first_environment_time);
+ assert_eq!(
+ app.pending_scene_update
+ .as_ref()
+ .unwrap()
+ .material_update
+ .active_materials[&0]
+ .phase_index,
+ 1
+ );
+ assert_eq!(
+ app.pending_scene_update.as_ref().unwrap().clock.time_ms,
+ 500
+ );
+ }
+ #[test]
+ fn live_pending_resize_reuses_225ms_packet_across_zero_extent_pause() {
+ let (mut app, key) = clock_test_app(None);
+ let started = Instant::now();
+ app.simulation_last_tick = started;
+ app.pressed_keys.insert(KeyCode::KeyE);
+
+ let first = app.sample_live_frame_clock_at(started + Duration::from_millis(225));
+ assert_eq!(first.id, SceneSampleId::Live(0));
+ assert_eq!(first.time_ms, 225);
+ assert!((first.delta_seconds - 0.225).abs() < 1.0e-6);
+ app.prepare_scene_update(first, [1280.0, 720.0])
+ .expect("sample one 225 ms live packet");
+ let first_frame = app.pending_scene_update.as_ref().unwrap();
+ let first_camera = first_frame.camera;
+ let expected_resized_camera = app
+ .free_camera
+ .expect("free-flight input controller")
+ .preview_frame(4.0 / 3.0);
+ let first_effect_updates = first_frame.effect_updates.clone();
+ let first_material_phase = first_frame.material_update.active_materials[&0].phase_index;
+ let weather = WeatherInput {
+ rain: WeatherState::new(true, 0.03, [0.7, 0.8, 1.0, 0.6]),
+ snow: WeatherState::new(true, 0.03, [0.9, 0.95, 1.0, 0.8]),
+ ..WeatherInput::clear()
+ };
+ let mut weather_system = EnvironmentSystem::new(0xC1_225);
+ let first_environment_frame = weather_system.update(
+ 0.225,
+ weather,
+ environment_camera_for_preview(&first_camera, 16.0 / 9.0, [1280.0, 720.0]),
+ );
+ let mut precipitation_counts = [0usize; 2];
+ for primitive in &first_environment_frame.primitives {
+ if let EnvironmentPrimitive::Particle { kind, .. } = primitive {
+ let slot = match kind {
+ PrecipitationKind::Rain => 0,
+ PrecipitationKind::Snow => 1,
+ };
+ precipitation_counts[slot] += 1;
+ }
+ }
+ assert!(precipitation_counts.iter().all(|count| *count >= 17));
+ app.pending_scene_update
+ .as_mut()
+ .unwrap()
+ .environment
+ .as_mut()
+ .unwrap()
+ .environment_frame = first_environment_frame.clone();
+ let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
+ let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
+ let first_environment_system_time =
+ app.environment.as_ref().unwrap().environment.time_seconds();
+ let first_vertices = app.mesh.vertices.clone();
+ assert!((first_camera.position[2] - 5.4).abs() < 1.0e-5);
+ assert_eq!(app.simulation_elapsed, Duration::from_millis(225));
+ assert_eq!(app.simulation_update_generation, 1);
+
+ app.pause_live_clock_at(started + Duration::from_millis(250));
+ let restored = started + Duration::from_secs(10);
+ app.resume_live_clock_at(restored);
+ app.free_camera.as_mut().unwrap().look_delta(64.0, -24.0);
+ let controller_after_input = app.free_camera.unwrap();
+ let retry = app.pending_or_sample_frame_clock();
+ assert_eq!(retry, first);
+ assert_eq!(app.simulation_update_generation, 1);
+ assert_eq!(app.simulation_elapsed, Duration::from_millis(225));
+ assert_eq!(app.simulation_last_tick, restored);
+
+ app.prepare_scene_update(retry, [1024.0, 768.0])
+ .expect("refresh only the pending packet's viewport");
+ let retry_frame = app.pending_scene_update.as_ref().unwrap();
+ assert_eq!(retry_frame.clock, first);
+ assert_eq!(retry_frame.viewport, [1024.0, 768.0]);
+ assert_eq!(retry_frame.aspect, 4.0 / 3.0);
+ assert_eq!(retry_frame.camera.position, first_camera.position);
+ assert_eq!(retry_frame.camera.forward, first_camera.forward);
+ assert_eq!(retry_frame.camera.right, first_camera.right);
+ assert_eq!(retry_frame.camera.up, first_camera.up);
+ assert_eq!(
+ retry_frame.camera.position,
+ expected_resized_camera.position
+ );
+ assert_eq!(retry_frame.camera.forward, expected_resized_camera.forward);
+ assert_eq!(retry_frame.camera.right, expected_resized_camera.right);
+ assert_eq!(retry_frame.camera.up, expected_resized_camera.up);
+ assert!(
+ (retry_frame.camera.vertical_fov - expected_resized_camera.vertical_fov).abs() < 1.0e-6
+ );
+ assert!(retry_frame
+ .camera
+ .clip_from_world
+ .iter()
+ .zip(expected_resized_camera.clip_from_world)
+ .all(|(actual, expected)| (actual - expected).abs() < 1.0e-5));
+ assert_ne!(
+ retry_frame.camera.clip_from_world,
+ first_camera.clip_from_world
+ );
+ assert_eq!(app.free_camera, Some(controller_after_input));
+ assert_eq!(retry_frame.effect_updates, first_effect_updates);
+ assert_eq!(
+ retry_frame.material_update.active_materials[&0].phase_index,
+ first_material_phase
+ );
+ let retry_environment_frame = &retry_frame.environment.as_ref().unwrap().environment_frame;
+ assert_eq!(
+ retry_environment_frame.point_lights,
+ first_environment_frame.point_lights
+ );
+ assert_eq!(
+ retry_environment_frame.sounds,
+ first_environment_frame.sounds
+ );
+ let mut reprojected_counts = [0usize; 2];
+ for (sampled, refreshed) in first_environment_frame
+ .primitives
+ .iter()
+ .zip(&retry_environment_frame.primitives)
+ {
+ let (
+ EnvironmentPrimitive::Particle {
+ kind: sampled_kind,
+ position: sampled_position,
+ velocity: sampled_velocity,
+ size: sampled_size,
+ screen: sampled_screen,
+ ..
+ },
+ EnvironmentPrimitive::Particle {
+ kind,
+ position,
+ velocity,
+ size,
+ screen,
+ ..
+ },
+ ) = (sampled, refreshed)
+ else {
+ panic!("rain and snow fixture contains only precipitation particles");
+ };
+ assert_eq!(kind, sampled_kind);
+ assert_eq!(position, sampled_position);
+ assert_eq!(velocity, sampled_velocity);
+ assert_ne!(screen, sampled_screen);
+ let slot = match kind {
+ PrecipitationKind::Rain => 0,
+ PrecipitationKind::Snow => 1,
+ };
+ reprojected_counts[slot] += 1;
+ assert_ne!(size, sampled_size);
+ }
+ assert!(reprojected_counts.iter().all(|count| *count >= 17));
+ assert_eq!(
+ control_random_snapshot(&app.control_effect_random_states[&key]),
+ first_random
+ );
+ assert_eq!(
+ app.environment.as_ref().unwrap().time_seconds,
+ first_environment_time
+ );
+ assert_eq!(
+ app.environment.as_ref().unwrap().environment.time_seconds(),
+ first_environment_system_time
+ );
+ assert_eq!(app.mesh.vertices, first_vertices);
+
+ assert_eq!(app.record_presented_scene_frame(), 0);
+ let next = app.sample_live_frame_clock_at(restored + Duration::from_millis(16));
+ assert_eq!(next.id, SceneSampleId::Live(1));
+ assert_eq!(next.time_ms, 241);
+ assert!((next.delta_seconds - 0.016).abs() < 1.0e-6);
+ assert_eq!(app.simulation_elapsed, Duration::from_millis(241));
+ app.prepare_scene_update(next, [1024.0, 768.0])
+ .expect("the next sample adopts mouse input accumulated during retry");
+ let next_camera = app.pending_scene_update.as_ref().unwrap().camera;
+ assert_ne!(next_camera.forward, first_camera.forward);
+ assert_eq!(next_camera.forward, controller_after_input.forward());
+ }
+
+ #[test]
+ fn replay_retries_keep_one_packet_until_presented_then_advance_with_new_viewport() {
+ let (mut app, key) = clock_test_app(Some(500));
+ app.render_scene_update()
+ .expect("redraw without a pending packet does nothing");
+ assert_eq!(app.simulation_update_generation, 0);
+
+ let first = app.sample_frame_clock();
+ assert_eq!(
+ first,
+ FrameClockSample {
+ id: SceneSampleId::Replay(0),
+ time_ms: 0,
+ delta_seconds: 0.0,
+ }
+ );
+ app.prepare_scene_update(first, [1280.0, 720.0])
+ .expect("prepare the first replay frame");
+ let first_frame = app.pending_scene_update.as_ref().unwrap();
+ let first_camera = first_frame.camera;
+ let first_viewport = first_frame.viewport;
+ let first_aspect = first_frame.aspect;
+ let first_effect_updates = first_frame.effect_updates.clone();
+ let first_environment_mesh = first_frame.environment.as_ref().unwrap().sky_mesh.clone();
+ let first_environment_frame = first_frame
+ .environment
+ .as_ref()
+ .unwrap()
+ .environment_frame
+ .clone();
+ let first_material_phase = first_frame.material_update.active_materials[&0].phase_index;
+ let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
+ let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
+ let first_vertices = app.mesh.vertices.clone();
+ assert_eq!(first_material_phase, 0);
+ assert_eq!(first_random.3, Some(SceneSampleId::Replay(0)));
+ assert_eq!(first_random.4, Some(0));
+ assert!(!first_environment_mesh.vertices.is_empty());
+
+ for _ in 0..2 {
+ app.render_scene_update()
+ .expect("renderer-free replay redraw is a retry");
+ let retry = app.sample_frame_clock();
+ assert_eq!(retry.id, SceneSampleId::Replay(0));
+ app.prepare_scene_update(retry, [1024.0, 768.0])
+ .expect("a resize retry reuses the current replay packet");
+ }
+ let retry_frame = app.pending_scene_update.as_ref().unwrap();
+ assert_eq!(retry_frame.camera.position, first_camera.position);
+ assert_eq!(retry_frame.camera.forward, first_camera.forward);
+ assert_eq!(retry_frame.camera.up, first_camera.up);
+ assert_eq!(retry_frame.viewport, [1024.0, 768.0]);
+ assert_eq!(retry_frame.aspect, 4.0 / 3.0);
+ assert_ne!(
+ retry_frame.camera.clip_from_world,
+ first_camera.clip_from_world
+ );
+ assert_ne!(retry_frame.viewport, first_viewport);
+ assert_ne!(retry_frame.aspect, first_aspect);
+ assert_eq!(retry_frame.effect_updates, first_effect_updates);
+ assert_eq!(
+ retry_frame.environment.as_ref().unwrap().sky_mesh,
+ first_environment_mesh
+ );
+ assert_eq!(
+ retry_frame.environment.as_ref().unwrap().environment_frame,
+ first_environment_frame
+ );
+ assert_eq!(
+ retry_frame.material_update.active_materials[&0].phase_index,
+ 0
+ );
+ assert_eq!(
+ control_random_snapshot(&app.control_effect_random_states[&key]),
+ first_random
+ );
+ assert_eq!(
+ app.environment.as_ref().unwrap().time_seconds,
+ first_environment_time
+ );
+ assert_eq!(app.mesh.vertices, first_vertices);
+ assert_eq!(app.frames_presented, 0);
+
+ assert_eq!(app.record_presented_scene_frame(), 0);
+ assert_eq!(app.frames_presented, 1);
+ assert!(app.pending_scene_update.is_none());
+ let next = app.sample_frame_clock();
+ assert_eq!(next.id, SceneSampleId::Replay(1));
+ assert_eq!(next.time_ms, 500);
+ assert_eq!(next.delta_seconds, 0.5);
+ app.prepare_scene_update(next, [1024.0, 768.0])
+ .expect("the next presented replay frame samples the resized view");
+ let next_frame = app.pending_scene_update.as_ref().unwrap();
+ assert_eq!(next_frame.viewport, [1024.0, 768.0]);
+ assert_eq!(next_frame.aspect, 4.0 / 3.0);
+ assert_ne!(
+ next_frame.camera.clip_from_world,
+ first_camera.clip_from_world
+ );
+ assert_eq!(
+ next_frame.material_update.active_materials[&0].phase_index,
+ 1
+ );
+ assert!(
+ (app.environment.as_ref().unwrap().time_seconds - first_environment_time - 0.5).abs()
+ < 1.0e-6
+ );
+ let next_random = control_random_snapshot(&app.control_effect_random_states[&key]);
+ assert_eq!(next_random.3, Some(SceneSampleId::Replay(1)));
+ assert_eq!(next_random.4, Some(500));
+ assert_ne!(next_random, first_random);
+ let next_camera = next_frame.camera;
+ let next_environment_mesh = next_frame.environment.as_ref().unwrap().sky_mesh.clone();
+ let next_environment_frame = next_frame
+ .environment
+ .as_ref()
+ .unwrap()
+ .environment_frame
+ .clone();
+
+ app.render_scene_update()
+ .expect("renderer-free draw does not advance the replay packet");
+ let repeated = app.sample_frame_clock();
+ assert_eq!(repeated.id, SceneSampleId::Replay(1));
+ app.prepare_scene_update(repeated, [800.0, 600.0])
+ .expect("duplicate replay frame is idempotent");
+ let repeated_frame = app.pending_scene_update.as_ref().unwrap();
+ assert_eq!(repeated_frame.viewport, [800.0, 600.0]);
+ assert_eq!(repeated_frame.aspect, 4.0 / 3.0);
+ assert_eq!(repeated_frame.camera, next_camera);
+ assert_eq!(
+ repeated_frame.environment.as_ref().unwrap().sky_mesh,
+ next_environment_mesh
+ );
+ assert_eq!(
+ repeated_frame
+ .environment
+ .as_ref()
+ .unwrap()
+ .environment_frame,
+ next_environment_frame
+ );
+ assert_eq!(
+ control_random_snapshot(&app.control_effect_random_states[&key]),
+ next_random
+ );
+ assert_eq!(
+ app.environment.as_ref().unwrap().time_seconds,
+ first_environment_time + 0.5
+ );
+ assert_eq!(app.frames_presented, 1);
+ }
+
+ #[test]
+ fn replay_environment_delta_uses_last_applied_clock_and_keeps_duplicates_at_zero() {
+ let initial = control_clock_ms_for_frame(500, 0, 101);
+ assert_eq!(initial, 0);
+ assert_eq!(replay_environment_delta_seconds(0, initial), 0.0);
+
+ let first = control_clock_ms_for_frame(500, 1, 101);
+ let repeated = control_clock_ms_for_frame(500, 1, 101);
+ assert_eq!(replay_environment_delta_seconds(initial, first), 0.005);
+ assert_eq!(replay_environment_delta_seconds(first, repeated), 0.0);
+ let next = control_clock_ms_for_frame(500, 2, 101);
+ assert_eq!(replay_environment_delta_seconds(first, next), 0.005);
+ assert_eq!(
+ replay_environment_delta_seconds(0, control_clock_ms_for_frame(500, 0, 1)),
+ 0.5
+ );
+ assert_eq!(replay_environment_delta_seconds(next, 0), 0.0);
+ }
+
+ #[test]
+ fn ambient_effect_screen_bounds_follow_vulkan_viewport_y_direction() {
+ let vertices = [
+ [-0.5, 0.25, 0.5],
+ [-0.5, 0.75, 0.5],
+ [0.5, 0.75, 0.5],
+ [0.5, 0.25, 0.5],
+ ];
+ let bounds = ambient_effect_projected_bounds(
+ &vertices,
+ [
+ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
+ ],
+ [100, 100],
+ )
+ .expect("visible clip-space quad");
+ assert_eq!(bounds, [25.0, 62.5, 75.0, 87.5]);
+ }
+
+ #[test]
+ fn native_tl_sprite_pixels_map_to_current_vulkan_viewport_at_z_999() {
+ let uv = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]];
+ let small = screen_tl_quad_vertices_pixels(
+ [160.0, 120.0],
+ 40.0,
+ 30.0,
+ [640.0, 480.0],
+ [1.0; 3],
+ 1.0,
+ uv,
+ );
+ let resized = screen_tl_quad_vertices_pixels(
+ [320.0, 240.0],
+ 80.0,
+ 60.0,
+ [1280.0, 960.0],
+ [1.0; 3],
+ 1.0,
+ uv,
+ );
+
+ for (small, resized) in small.iter().zip(resized) {
+ for axis in 0..2 {
+ assert!((small.position[axis] - resized.position[axis]).abs() < 1.0e-6);
+ }
+ assert_eq!(small.position[2], 0.999);
+ assert_eq!(resized.position[2], 0.999);
+ }
+ for (actual, expected) in [
+ (small[0].position, [-0.625, -0.375, 0.999]),
+ (small[2].position, [-0.625, -0.625, 0.999]),
+ ] {
+ for axis in 0..3 {
+ assert!((actual[axis] - expected[axis]).abs() < 1.0e-6);
+ }
+ }
+ assert_eq!(small[0].uv, [0.0, 1.0]);
+ assert_eq!(small[2].uv, [0.0, 0.0]);
+ }
+
+ #[test]
+ fn parses_control_clock_capture() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
@@ -8488,8 +14176,18 @@ mod tests {
"--control-clock-ms",
"500",
]))
- .expect("valid bounded replay arguments");
+ .expect("controlled clock capture arguments");
assert_eq!(parsed.control_clock_ms, Some(500));
+ assert!(Args::parse(&strings(&[
+ "--root",
+ "testdata/IS",
+ "--mission",
+ "MISSIONS/Autodemo.00/data.tma",
+ "--control-clock-ms",
+ "500",
+ ]))
+ .expect_err("replay needs multiple frames")
+ .contains("at least 2"));
}
#[test]
@@ -8504,9 +14202,12 @@ mod tests {
};
let mut bindings = vec![MaterialAnimationBinding {
initial_phase_index: 0,
+ normalized_phase_override: None,
+ ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices: vec![10, 11],
range_indices: Vec::new(),
+ pipeline_category: 0,
}],
base: MaterialPhaseBinding {
document: empty_mat0,
@@ -8527,6 +14228,7 @@ mod tests {
bindings[binding_index].variants.push(MaterialVariantSet {
phase_material_indices: vec![20, 21],
range_indices: vec![7],
+ pipeline_category: 0,
});
let lightmap_variant = &bindings[binding_index].variants[1];
@@ -8818,6 +14520,322 @@ mod tests {
assert_eq!(sky_normal([-128, 127, -64]), [-1.0, 127.0 / 128.0, -0.5]);
}
+ fn set_test_terrain_lod_slots(
+ terrain: &mut fparkan_terrain_format::LandMeshDocument,
+ node_slots: &[[u16; 2]],
+ face_ranges: &[[u16; 2]],
+ ) {
+ const NODE_STRIDE: usize = 38;
+ let mut nodes = vec![u8::MAX; node_slots.len() * NODE_STRIDE];
+ for (node_index, [lod0_slot, lod1_slot]) in node_slots.iter().copied().enumerate() {
+ let offset = node_index * NODE_STRIDE + 8;
+ nodes[offset..offset + 2].copy_from_slice(&lod0_slot.to_le_bytes());
+ nodes[offset + 2..offset + 4].copy_from_slice(&lod1_slot.to_le_bytes());
+ }
+ let mut slots = vec![[0_u8; 68]; face_ranges.len()];
+ for (slot, [start, count]) in slots.iter_mut().zip(face_ranges.iter().copied()) {
+ slot[..2].copy_from_slice(&start.to_le_bytes());
+ slot[2..4].copy_from_slice(&count.to_le_bytes());
+ }
+ terrain.nodes_raw = nodes;
+ terrain.slots.slots_raw = slots;
+ }
+
+ fn mission_root_for_basement(position: [f32; 3], raw_after_name: u32) -> MissionObjectDraft {
+ MissionObjectDraft {
+ original_id: None,
+ resource_name_raw: b"BUILDS/example.dat".to_vec(),
+ raw_after_resource: 0,
+ identity_or_clan_raw: 0,
+ raw_after_name,
+ unit_dat_header_flags: Some(0x8000_0000),
+ position,
+ orientation_raw: [0.0; 3],
+ scale: [1.0; 3],
+ visual_ids: Vec::new(),
+ properties: Vec::new(),
+ unit_components: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn basement_delta_updates_one_common_root_for_mesh_and_attached_component() {
+ let roots = [
+ mission_root_for_basement([1.0, 2.0, 100.0], 0),
+ mission_root_for_basement([3.0, 4.0, 200.0], 1),
+ ];
+ let footprints = [
+ MissionBasementFootprint {
+ object_index: 0,
+ outer: Vec::new(),
+ inner: Vec::new(),
+ flatten_inner_to_mean_outer_z: true,
+ },
+ MissionBasementFootprint {
+ object_index: 1,
+ outer: Vec::new(),
+ inner: Vec::new(),
+ flatten_inner_to_mean_outer_z: false,
+ },
+ ];
+ let placements = [
+ BasementPlacement {
+ placement_delta: 12.5,
+ },
+ BasementPlacement {
+ placement_delta: 25.0,
+ },
+ ];
+ let adjusted = apply_basement_placement_deltas(&roots, &footprints, &placements)
+ .expect("aligned native placements");
+ assert_eq!(adjusted[0].position, [1.0, 2.0, 87.5]);
+ assert_eq!(adjusted[1].position, roots[1].position);
+
+ let transform = LegacyIron3dEulerTransform {
+ translation: adjusted[0].position,
+ orientation_radians: adjusted[0].orientation_raw,
+ };
+ assert_eq!(
+ transform.try_transform_scaled_point([0.0, 0.0, 0.0], adjusted[0].scale),
+ Some([1.0, 2.0, 87.5])
+ );
+ assert_eq!(
+ transform.try_transform_scaled_point([0.0, 0.0, 4.0], adjusted[0].scale),
+ Some([1.0, 2.0, 91.5]),
+ "the attached component inherits the root shift once"
+ );
+ }
+
+ #[test]
+ fn basement_projection_shadows_and_queries_share_generated_base_only_faces() {
+ let mut land = fparkan_terrain_format::LandMeshDocument {
+ streams: Vec::new(),
+ nodes_raw: Vec::new(),
+ slots: fparkan_terrain_format::TerrainSlotTable {
+ header_raw: Vec::new(),
+ slots_raw: Vec::new(),
+ },
+ positions: vec![[0.0, 0.0, 5.0], [20.0, 0.0, 5.0], [0.0, 20.0, 5.0]],
+ normals: vec![[127, 0, 0, 0], [0, 127, 0, 0], [0, 0, 127, 0]],
+ uv0: vec![[256, 128], [1024, 256], [512, 1536]],
+ accelerator: Vec::new(),
+ aux14: Vec::new(),
+ aux18: Vec::new(),
+ faces: vec![fparkan_terrain_format::TerrainFace28 {
+ flags: FullSurfaceMask(0x400),
+ material_tag: 0xff00,
+ aux_tag: 0,
+ vertices: [0, 1, 2],
+ neighbors: [None, None, None],
+ tail_raw: [0; 8],
+ raw: [0; 28],
+ }],
+ };
+ set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
+ let source_mesh = project_land_msh_to_static_mesh_in_world_space(&land)
+ .expect("project one LOD0 terrain face");
+ let source_world = TerrainWorld::from_land_msh(&land).expect("source terrain");
+ let (empty_mesh, empty_shadows, empty_query) =
+ prepare_static_terrain_geometry(&land, source_mesh.clone(), &source_world, None)
+ .expect("keep unmodified terrain when there are no foundations");
+ assert_eq!(empty_mesh, source_mesh);
+ assert_eq!(empty_query.surface_count(), source_world.surface_count());
+ assert_eq!(empty_shadows.len(), 1);
+ assert!(empty_mesh
+ .vertices
+ .iter()
+ .any(|vertex| vertex.normal != [0.0; 3]));
+ assert!(empty_mesh
+ .vertices
+ .iter()
+ .any(|vertex| vertex.uv != [0.0; 2]));
+ let band_positions = [
+ [-18.25, -18.25, 5.0],
+ [-2.25, -18.25, 5.0],
+ [-18.25, -2.25, 5.0],
+ ];
+ let mut foundation = BasementFoundation {
+ fragments: vec![
+ BasementFoundationFragment {
+ source_face: Some(0),
+ surface_mask: FullSurfaceMask(0x400),
+ positions: [[0.0, 0.0, 5.0], [20.0, 0.0, 5.0], [0.0, 20.0, 5.0]],
+ },
+ BasementFoundationFragment {
+ source_face: None,
+ surface_mask: FullSurfaceMask(0x8300),
+ positions: band_positions,
+ },
+ ],
+ placements: Vec::new(),
+ };
+ let (render_mesh, shadow_receivers) =
+ project_basement_foundation(&land, &source_mesh, &mut foundation)
+ .expect("project canonical basement faces");
+ let expected = foundation.fragments.clone();
+ assert_eq!(render_mesh.draw_ranges.len(), 2);
+ assert!(render_mesh
+ .draw_ranges
+ .iter()
+ .all(|range| range.material_index == 0xff00));
+ assert!(!render_mesh.draw_ranges[0].terrain_base_only);
+ assert!(render_mesh.draw_ranges[1].terrain_base_only);
+ assert_eq!(render_mesh.draw_ranges[1].first_index, 3);
+ assert_eq!(shadow_receivers.len(), expected.len());
+
+ for (triangle_index, fragment) in expected.iter().enumerate() {
+ let indices: [u32; 3] = render_mesh.indices[triangle_index * 3..triangle_index * 3 + 3]
+ .try_into()
+ .expect("projected foundation triangle");
+ let positions = indices.map(|index| render_mesh.vertices[index as usize].position);
+ assert_eq!(positions, fragment.positions);
+ assert_eq!(
+ shadow_receivers[triangle_index].positions,
+ fragment.positions
+ );
+ assert_eq!(
+ shadow_receivers[triangle_index].mask,
+ fragment.surface_mask.0
+ );
+ }
+ assert_eq!(
+ basement_band_uv_vertices(band_positions)
+ .expect("native per-face UVs")
+ .map(|vertex| vertex.uv0),
+ [[815, 815], [1896, 815], [815, 1896]],
+ "negative coordinates use one native floor origin per triangle"
+ );
+ assert!(render_mesh.vertices[3..6]
+ .iter()
+ .flat_map(|vertex| vertex.uv)
+ .all(|value| (value * 1024.0).fract() == 0.0));
+
+ let query_world = source_world
+ .with_basement_foundation(foundation)
+ .expect("install canonical geometry");
+ assert_eq!(query_world.surface_count(), expected.len());
+ assert_eq!(
+ query_world
+ .basement_foundation()
+ .expect("installed foundation")
+ .fragments,
+ expected
+ );
+ assert_eq!(query_world.height_at([-14.0, -14.0]).unwrap(), Some(5.0));
+ }
+
+ #[test]
+ fn basement_band_normals_sum_packed_faces_for_native_welded_vertices() {
+ let mut foundation = BasementFoundation {
+ fragments: vec![
+ BasementFoundationFragment {
+ source_face: None,
+ surface_mask: FullSurfaceMask(0x8300),
+ positions: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
+ },
+ BasementFoundationFragment {
+ source_face: None,
+ surface_mask: FullSurfaceMask(0x8300),
+ positions: [[0.005, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 1.0]],
+ },
+ ],
+ placements: Vec::new(),
+ };
+ let (normals, uv0_words) = canonicalize_basement_band_vertices(&mut foundation)
+ .expect("native packed normal pass");
+ let first = normals[0].expect("first synthetic face");
+ let second = normals[1].expect("second synthetic face");
+ assert_eq!(first[0], second[0], "sub-centimeter XY vertices are welded");
+ assert_eq!(foundation.fragments[1].positions[0], [0.0, 0.0, 0.0]);
+ assert_eq!(
+ first[2], second[1],
+ "shared vertices use both packed face normals"
+ );
+ assert_eq!(uv0_words[1].expect("second face UV words")[0], [0, 0]);
+ assert_eq!(first[0][1], 0.0);
+ assert_ne!(first[0], [0.0, 0.0, 127.0 / 128.0]);
+ }
+
+ #[test]
+ fn basement_face_normal_distinguishes_degenerate_from_non_finite() {
+ assert_eq!(
+ packed_basement_face_normal([[0.0; 3], [1.0; 3], [2.0; 3]])
+ .expect("zero normal is a native zero contribution"),
+ None
+ );
+ assert!(packed_basement_face_normal([
+ [f32::NAN, 0.0, 0.0],
+ [1.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0]
+ ])
+ .is_err());
+ }
+
+ #[test]
+ fn c00_m02_world_terrain_edge_rounding_keeps_overlay_alpha_in_range() {
+ // DATA/MAPS/Tut_2/Land.msh face 2525: this split point lands about
+ // 1.35e-6 outside one source edge after the contour kernel's f32
+ // world-coordinate output is converted back to f64 barycentrics.
+ let source = [
+ VulkanStaticVertex {
+ position: [815.20465, 281.67642, 320.0],
+ color: [1.0; 3],
+ normal: [0.0, 0.0, 1.0],
+ uv: [0.0; 2],
+ detail_uv: [0.0; 2],
+ overlay_alpha: 1.0,
+ },
+ VulkanStaticVertex {
+ position: [775.4386, 308.18713, 360.00003],
+ color: [1.0; 3],
+ normal: [0.0, 0.0, 1.0],
+ uv: [0.0; 2],
+ detail_uv: [0.0; 2],
+ overlay_alpha: 1.0,
+ },
+ VulkanStaticVertex {
+ position: [775.4386, 288.3041, 361.6667],
+ color: [1.0; 3],
+ normal: [0.0, 0.0, 1.0],
+ uv: [0.0; 2],
+ detail_uv: [0.0; 2],
+ overlay_alpha: 1.0,
+ },
+ ];
+ let split_position = [800.6648, 291.3697, 334.62543];
+ let point = [f64::from(split_position[0]), f64::from(split_position[1])];
+ let a = [
+ f64::from(source[0].position[0]),
+ f64::from(source[0].position[1]),
+ ];
+ let b = [
+ f64::from(source[1].position[0]),
+ f64::from(source[1].position[1]),
+ ];
+ let c = [
+ f64::from(source[2].position[0]),
+ f64::from(source[2].position[1]),
+ ];
+ let denominator = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
+ let second_weight =
+ ((point[0] - a[0]) * (c[1] - a[1]) - (point[1] - a[1]) * (c[0] - a[0])) / denominator;
+ let third_weight =
+ ((b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (point[0] - a[0])) / denominator;
+ let raw_overlay_alpha = [
+ 1.0 - second_weight - third_weight,
+ second_weight,
+ third_weight,
+ ]
+ .map(|weight| weight as f32)
+ .into_iter()
+ .sum::<f32>();
+ assert!(raw_overlay_alpha > 1.0 && raw_overlay_alpha <= 1.0 + 1.0e-5);
+ let vertex = interpolate_static_terrain_vertex(split_position, source)
+ .expect("bounded alpha rounding at a contour split");
+ assert_eq!(vertex.overlay_alpha, 1.0);
+ assert!((0.0..=1.0).contains(&vertex.overlay_alpha));
+ }
+
#[test]
fn sun_visibility_uses_a_valid_ray_and_fails_open_without_surfaces() {
let camera = FreeFlightCamera {
@@ -8859,14 +14877,9 @@ mod tests {
raw: [0; 28],
}],
};
- set_test_terrain_lod0_range(&mut land);
+ set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
let shadow_receivers = terrain_shadow_receivers(&land).expect("raw-height receivers");
assert_eq!(shadow_receivers[0].positions[0], [0.0, 0.0, 32.0]);
- let mut invalid_shadow_face = land.clone();
- invalid_shadow_face.faces[0].vertices[2] = u16::MAX;
- assert!(terrain_shadow_receivers(&invalid_shadow_face)
- .expect_err("bad terrain indices must fail instead of inventing vertices")
- .contains("unknown vertex 65535"));
let terrain = TerrainWorld::from_land_msh(&land).expect("synthetic terrain");
assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(32.0));
let floor_camera =
@@ -8942,7 +14955,7 @@ mod tests {
raw: [0; 28],
}],
};
- set_test_terrain_lod0_range(&mut excluded_land);
+ set_test_terrain_lod_slots(&mut excluded_land, &[[0, u16::MAX]], &[[0, 1]]);
let excluded_terrain =
TerrainWorld::from_land_msh(&excluded_land).expect("excluded terrain");
assert!(native_sun_unoccluded_with_world(
@@ -8989,7 +15002,7 @@ mod tests {
raw: [0; 28],
}],
};
- set_test_terrain_lod0_range(&mut land);
+ set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
let terrain = TerrainWorld::from_land_msh(&land).expect("sloped terrain");
assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(33.5));
assert_eq!(terrain.height_at([0.75, 0.25]).unwrap(), Some(34.5));
@@ -9021,15 +15034,27 @@ mod tests {
assert_eq!(camera.position, [0.75, 0.25, 36.5]);
let mut outside_camera = FreeFlightCamera {
- position: [2.0, 2.0, -17.0],
+ position: [2.0, -2.0, -17.0],
ground_clearance: 2.0,
..camera
};
- let unchanged = outside_camera.position;
outside_camera
.keep_above_terrain(&terrain)
- .expect("missing terrain sample leaves the camera alone");
- assert_eq!(outside_camera.position, unchanged);
+ .expect("clamp to the map before sampling the floor");
+ assert_eq!(outside_camera.position, [1.0, 0.0, 36.0]);
+
+ outside_camera.position = [-2.0, 2.0, 100.0];
+ outside_camera
+ .keep_above_terrain(&terrain)
+ .expect("clamp the other map edges without limiting altitude");
+ assert_eq!(outside_camera.position, [0.0, 1.0, 100.0]);
+
+ outside_camera.move_speed = 40.0;
+ outside_camera.advance(&HashSet::from([KeyCode::KeyW, KeyCode::ShiftLeft]), 0.1);
+ outside_camera
+ .keep_above_terrain(&terrain)
+ .expect("accelerated movement still stops at the map edge");
+ assert_eq!(outside_camera.position, [1.0, 1.0, 100.0]);
let mut unsupported_camera = FreeFlightCamera {
position: [0.25, 0.25, 35.5],
@@ -9043,6 +15068,49 @@ mod tests {
}
#[test]
+ #[ignore = "requires the original GOG installation"]
+ fn free_flight_camera_stops_at_original_map_edges_under_accelerated_movement() {
+ let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation");
+ let mut engine =
+ create(EngineServices::new(Arc::new(DirectoryVfs::new(root)))).expect("engine");
+ load_mission_static_preview(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ )
+ .expect("original mission");
+ let terrain = loaded_terrain(&engine).expect("original terrain");
+ let [min, max] = terrain.surface_bounds().expect("original map bounds");
+ let keys = HashSet::from([KeyCode::KeyW, KeyCode::ShiftLeft]);
+ for (axis, yaw, edge) in [
+ (0, 0.0, max[0]),
+ (0, std::f32::consts::PI, min[0]),
+ (1, std::f32::consts::FRAC_PI_2, max[1]),
+ (1, -std::f32::consts::FRAC_PI_2, min[1]),
+ ] {
+ let mut camera = FreeFlightCamera::from_bounds(min, max).expect("camera");
+ camera.position = [
+ (min[0] + max[0]) * 0.5,
+ (min[1] + max[1]) * 0.5,
+ max[2] + 10.0,
+ ];
+ camera.yaw = yaw;
+ camera.pitch = 0.0;
+ for _ in 0..512 {
+ camera.advance(&keys, 0.1);
+ camera.keep_above_terrain(terrain).expect("map constraint");
+ for axis in 0..2 {
+ assert!((min[axis]..=max[axis]).contains(&camera.position[axis]));
+ }
+ }
+ assert_eq!(camera.position[axis], edge);
+ }
+ }
+
+ #[test]
fn world_sort_key_uses_the_first_indexed_vertex() {
let mesh = VulkanStaticMesh::smoke_triangle();
let distance = first_indexed_vertex_distance(&mesh, 0, [0.0, -0.55, -3.0])
@@ -9149,6 +15217,71 @@ mod tests {
}
#[test]
+ fn controlled_readback_sequence_requires_and_parses_replay_clock() {
+ let parsed = Args::parse(&strings(&[
+ "--root",
+ "testdata/IS",
+ "--mission",
+ "MISSIONS/Autodemo.00/data.tma",
+ "--frames",
+ "11",
+ "--control-clock-ms",
+ "0",
+ "--readback-sequence-out",
+ "target/frames",
+ ]))
+ .expect("valid fixed-clock readback sequence");
+ assert_eq!(
+ parsed.readback_sequence_out,
+ Some(PathBuf::from("target/frames"))
+ );
+ assert_eq!(parsed.control_clock_ms, Some(0));
+
+ let error = Args::parse(&strings(&[
+ "--root",
+ "testdata/IS",
+ "--mission",
+ "MISSIONS/Autodemo.00/data.tma",
+ "--frames",
+ "11",
+ "--readback-sequence-out",
+ "target/frames",
+ ]))
+ .expect_err("sequence captures need deterministic clock input");
+ assert!(error.contains("requires --control-clock-ms"));
+ }
+
+ #[test]
+ fn readback_sequence_writes_clock_named_frame_without_overwrite() {
+ let nonce = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock after epoch")
+ .as_nanos();
+ let directory = std::env::temp_dir().join(format!(
+ "fparkan-readback-sequence-{}-{nonce}",
+ std::process::id()
+ ));
+ let artifact = VulkanReadbackArtifact {
+ format: 37,
+ width: 2,
+ height: 1,
+ bytes: vec![1, 2, 3, 4, 5, 6, 7, 8],
+ };
+ let path = write_readback_sequence_frame(&directory, 3, 50, &artifact)
+ .expect("write frame artifact");
+ assert_eq!(
+ path.file_name().and_then(|name| name.to_str()),
+ Some("frame-000003-clock-0000000050ms-2x1.rgba")
+ );
+ assert_eq!(
+ std::fs::read(&path).expect("read frame artifact"),
+ artifact.bytes
+ );
+ assert!(write_readback_sequence_frame(&directory, 3, 50, &artifact).is_err());
+ std::fs::remove_dir_all(directory).expect("remove frame artifact directory");
+ }
+
+ #[test]
fn parses_static_animation_frame() {
let parsed = Args::parse(&strings(&[
"--root",
@@ -9324,6 +15457,59 @@ mod tests {
}
#[test]
+ fn parented_camera_preview_uses_effective_world_matrix_and_legacy_uses_local() {
+ let local_words = [
+ 1065353216, 0, 0, 1065353216, 0, 1065353216, 0, 1073741824, 0, 0, 1065353216,
+ 1077936128, 0, 0, 0, 1065353216,
+ ];
+ let world_words = [
+ 1065353216, 0, 0, 1092616192, 0, 1065353216, 0, 1101004800, 0, 0, 1065353216,
+ 1106247680, 0, 0, 0, 1065353216,
+ ];
+ let make_json = |extra: &str| {
+ format!(
+ "{{\"schema\":\"fparkan-legacy-camera-v1\",\"render_input_usable\":true,\"selector0_words\":{:?},{}\"viewport\":[0,0,1280,1024],\"near_plane\":0.5,\"far_plane\":700.0,\"field_of_view_radians\":1.04}}",
+ local_words, extra
+ )
+ };
+ let legacy = parse_legacy_camera_capture(make_json("").as_bytes())
+ .expect("legacy camera without parent metadata");
+ assert_eq!(legacy.preview_matrix_words().unwrap(), local_words);
+ assert_eq!(
+ legacy.preview_camera_frame().unwrap().position,
+ [1.0, 2.0, 3.0]
+ );
+
+ let parented = parse_legacy_camera_capture(
+ make_json(&format!(
+ "\"parented_camera\":true,\"effective_world_matrix_space\":\"world\",\"effective_world_matrix_words\":{:?},",
+ world_words
+ ))
+ .as_bytes(),
+ )
+ .expect("parented camera with effective world matrix");
+ assert_eq!(parented.preview_matrix_words().unwrap(), world_words);
+ let preview = parented.preview_camera_frame().unwrap();
+ assert_eq!(preview.position, [10.0, 20.0, 30.0]);
+ assert_ne!(
+ preview.position,
+ legacy.preview_camera_frame().unwrap().position
+ );
+
+ let missing_world = make_json("\"parented_camera\":true,");
+ assert!(parse_legacy_camera_capture(missing_world.as_bytes())
+ .expect_err("parented local-only input must be rejected")
+ .contains("parented legacy camera requires an effective world-space matrix"));
+ let missing_marker = make_json(&format!(
+ "\"parented_camera\":true,\"effective_world_matrix_words\":{:?},",
+ world_words
+ ));
+ assert!(parse_legacy_camera_capture(missing_marker.as_bytes())
+ .expect_err("world matrix requires space marker")
+ .contains("effective_world_matrix_space and effective_world_matrix_words"));
+ }
+
+ #[test]
fn parented_camera_preview_uses_effective_world_matrix_fixture() {
let fixture = include_bytes!("../testdata/legacy-camera-parented.json");
let capture = parse_legacy_camera_capture(fixture).expect("valid parented camera fixture");
@@ -9649,303 +15835,1064 @@ mod tests {
}
#[test]
- fn native_tl_sprite_pixels_use_current_viewport_and_preserve_depth_999() {
- let uv = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]];
- let small = screen_tl_quad_vertices_pixels(
- [160.0, 120.0],
- 40.0,
- 30.0,
- [640.0, 480.0],
- [1.0; 3],
- 1.0,
- uv,
+ fn control_type4_basis_branches_preserve_anisotropic_owner_scale() {
+ let owner_scale = [2.0, 3.0, 4.0];
+ let equal = [
+ control_point([0.0; 3], [1.0, 1.0, 0.0]),
+ control_point([0.0; 3], [1.0, 1.0, 0.0]),
+ control_point([0.0; 3], [1.0, 1.0, 0.0]),
+ ];
+ let equal_matrix =
+ control_type4_local_matrix([&equal[0], &equal[1], &equal[2]], owner_scale).unwrap();
+ assert_matrix_columns(
+ equal_matrix,
+ [
+ [2.0, 3.0, 0.0],
+ [-3.0, 2.0, 0.0],
+ [0.0, 0.0, 13.0_f32.sqrt()],
+ ],
);
- let resized = screen_tl_quad_vertices_pixels(
- [320.0, 240.0],
- 80.0,
- 60.0,
- [1280.0, 960.0],
- [1.0; 3],
- 1.0,
- uv,
+
+ let unique = [
+ control_point([1.0; 3], [1.0, 2.0, 3.0]),
+ control_point([2.0; 3], [4.0, 5.0, 6.0]),
+ control_point([3.0; 3], [-1.0, 1.0, 2.0]),
+ ];
+ let unique_matrix =
+ control_type4_local_matrix([&unique[0], &unique[1], &unique[2]], owner_scale).unwrap();
+ assert_matrix_columns(
+ unique_matrix,
+ [[2.0, 6.0, 12.0], [8.0, 15.0, 24.0], [-2.0, 3.0, 8.0]],
);
- let wide = screen_tl_quad_vertices_pixels(
- [640.0, 360.0],
- 100.0,
- 45.0,
- [1280.0, 720.0],
- [1.0; 3],
- 1.0,
- uv,
+ assert_eq!(
+ [
+ unique_matrix[0][3],
+ unique_matrix[1][3],
+ unique_matrix[2][3]
+ ],
+ [4.0, 6.0, 8.0]
);
- for (small, resized) in small.iter().zip(resized) {
- for axis in 0..2 {
- assert!((small.position[axis] - resized.position[axis]).abs() < 1.0e-6);
- }
- assert_eq!(small.position[2], 0.999);
- assert_eq!(resized.position[2], 0.999);
- }
- assert_eq!(small[0].uv, [0.0, 1.0]);
- assert_eq!(small[2].uv, [0.0, 0.0]);
- for (actual, expected) in [
- (wide[0].position, [-0.15625, 0.125, 0.999]),
- (wide[2].position, [-0.15625, -0.125, 0.999]),
+ for (directions, expected) in [
+ (
+ [[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
+ [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 2.0]],
+ ),
+ (
+ [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
+ [[0.0, 0.0, 4.0], [0.0, -1.0, 0.0], [4.0, 0.0, 0.0]],
+ ),
+ (
+ [[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]],
+ [[0.0, 3.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 3.0]],
+ ),
+ (
+ [[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
+ [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 2.0]],
+ ),
] {
- for axis in 0..3 {
- assert!((actual[axis] - expected[axis]).abs() < 1.0e-6);
- }
+ let repeated = directions.map(|direction| control_point([0.0; 3], direction));
+ let matrix =
+ control_type4_local_matrix([&repeated[0], &repeated[1], &repeated[2]], owner_scale)
+ .unwrap();
+ assert_matrix_columns(matrix, expected);
}
}
#[test]
- fn native_tl_depth_999_is_nearer_than_opaque_geometry_at_320_units() {
- let camera = FreeFlightCamera {
- position: [0.0; 3],
- yaw: 0.0,
- pitch: 0.0,
- horizontal_fov: 1.3,
- near_plane: 0.5,
- far_plane: 700.0,
- move_speed: 1.0,
- ground_clearance: 0.0,
- };
- let frame = camera.preview_frame(16.0 / 9.0);
- let opaque_clip = row_vector_transform([320.0, 0.0, 0.0, 1.0], frame.clip_from_world);
- let opaque_depth = opaque_clip[2] / opaque_clip[3];
+ fn control_type4_world_matrix_rotates_basis_and_keeps_scale_off_translation() {
+ let point = control_point([1.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
+ let local =
+ control_type4_effect_local_matrix([&point, &point, &point], [2.0, 3.0, 4.0]).unwrap();
+ let matrix = control_type4_world_from_effect(
+ local,
+ [2.0, 3.0, 4.0],
+ LegacyIron3dEulerTransform {
+ translation: [10.0, 20.0, 30.0],
+ orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
+ },
+ None,
+ VulkanNodePose {
+ translation: [0.0; 3],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ )
+ .unwrap();
- assert!((opaque_depth - 0.999151_18).abs() < 1.0e-6);
- assert!(0.999 < opaque_depth);
+ assert_matrix_columns(
+ matrix,
+ [[0.0, 4.0, 0.0], [-9.0, 0.0, 0.0], [0.0, 0.0, 16.0]],
+ );
+ for (actual, expected) in [matrix[0][3], matrix[1][3], matrix[2][3]]
+ .into_iter()
+ .zip([10.0, 22.0, 30.0])
+ {
+ assert!((actual - expected).abs() < 1.0e-5);
+ }
}
- fn facing_mesh(
- batch_flags: u16,
- positions: &[[f32; 3]],
- ) -> (VulkanStaticMesh, VulkanStaticDrawRange) {
- let range = VulkanStaticDrawRange {
- first_index: 0,
- index_count: u32::try_from(positions.len()).expect("fixture index count fits u32"),
- material_index: 0,
- lightmap_index: 0,
- batch_flags,
- pipeline_state: LegacyPipelineState::default(),
- terrain_base_only: false,
- alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
+ #[test]
+ fn mounted_control_effect_uses_component_pose_before_owner_world_transform() {
+ let point = control_point([1.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
+ let mount_pose = VulkanNodePose {
+ translation: [1.0, 2.0, 3.0],
+ rotation: [
+ 0.0,
+ 0.0,
+ std::f32::consts::FRAC_1_SQRT_2,
+ std::f32::consts::FRAC_1_SQRT_2,
+ ],
};
- let mesh = VulkanStaticMesh {
- vertices: positions
- .iter()
- .copied()
- .map(|position| VulkanStaticVertex {
- position,
- color: [1.0; 3],
- normal: [0.0, 0.0, 1.0],
- uv: [0.0; 2],
- detail_uv: [0.0; 2],
- overlay_alpha: 0.0,
- })
- .collect(),
- indices: (0..positions.len())
- .map(|index| u32::try_from(index).expect("fixture index fits u32"))
- .collect(),
- draw_ranges: vec![range],
+ let owner_node_pose = VulkanNodePose {
+ translation: [0.0; 3],
+ rotation: [0.0, 0.0, 0.0, 1.0],
};
- (mesh, range)
- }
+ let local = control_type4_effect_local_matrix([&point, &point, &point], [1.0; 3]).unwrap();
+ let matrix = control_type4_world_from_effect(
+ local,
+ [2.0, 3.0, 4.0],
+ LegacyIron3dEulerTransform {
+ translation: [10.0, 20.0, 30.0],
+ orientation_radians: [0.0; 3],
+ },
+ Some(mount_pose),
+ owner_node_pose,
+ )
+ .unwrap();
- fn native_front_triangle() -> [[f32; 3]; 3] {
- [[-0.2, -0.2, -1.0], [0.2, -0.2, -1.0], [0.0, 0.2, -1.0]]
+ for (actual, expected) in [matrix[0][3], matrix[1][3], matrix[2][3]]
+ .into_iter()
+ .zip([12.0, 29.0, 42.0])
+ {
+ assert!((actual - expected).abs() < 1.0e-5);
+ }
}
- fn camera_face_triangle(camera: PreviewCameraFrame, mirror: bool) -> [[f32; 3]; 3] {
- let center = add3(camera.position, scale3(camera.forward, 10.0));
- let mirror = if mirror { -1.0 } else { 1.0 };
- [
- add3(
- add3(center, scale3(camera.right, -2.0 * mirror)),
- scale3(camera.up, -2.0),
+ #[test]
+ fn plant_node23_control_points_match_captured_native_world_positions() {
+ let root_transform = LegacyIron3dEulerTransform {
+ translation: [323.55048, 806.97266, 0.36829287],
+ orientation_radians: [0.0, 0.0, 1.6262363],
+ };
+ let owner_node_pose = VulkanNodePose {
+ translation: [0.0, -68.15999, 21.12],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ };
+ let points = [
+ (
+ [-12.964036, 12.991658, -21.119995],
+ [379.35242, 797.0855, 0.3682986],
),
- add3(
- add3(center, scale3(camera.right, 2.0 * mirror)),
- scale3(camera.up, -2.0),
+ (
+ [13.49774, 12.991658, -21.119995],
+ [377.8861, 823.5066, 0.3682986],
),
- add3(center, scale3(camera.up, 2.0)),
- ]
- }
+ (
+ [9.507235, -3.006884, -21.119993],
+ [394.08118, 820.40875, 0.3683005],
+ ),
+ (
+ [-9.080112, -3.2843084, -21.119991],
+ [395.38815, 801.86536, 0.3683024],
+ ),
+ ];
- fn projected_vulkan_area(camera: PreviewCameraFrame, triangle: [[f32; 3]; 3]) -> f32 {
- let screen = triangle.map(|point| {
- let clip =
- row_vector_transform([point[0], point[1], point[2], 1.0], camera.clip_from_world);
- assert!(clip[3] > 0.0 && clip.iter().all(|value| value.is_finite()));
- // This is the positive-height viewport transform used by the Vulkan renderer.
- [
- (clip[0] / clip[3] + 1.0) * 0.5,
- (clip[1] / clip[3] + 1.0) * 0.5,
- ]
- });
- let shoelace = (0..3)
- .map(|index| {
- let next = (index + 1) % 3;
- screen[index][0] * screen[next][1] - screen[next][0] * screen[index][1]
- })
- .sum::<f32>()
- * 0.5;
- // Vulkan's framebuffer signed-area definition is the negative shoelace area.
- -shoelace
+ for (local_position, expected_world) in points {
+ let point = control_point(local_position, [1.0, 0.0, 0.0]);
+ let local = control_type4_effect_local_matrix([&point, &point, &point], [1.0; 3])
+ .expect("CPT descriptor local matrix");
+ let world = control_type4_world_from_effect(
+ local,
+ [1.0; 3],
+ root_transform,
+ None,
+ owner_node_pose,
+ )
+ .expect("attached Control owner matrix");
+ for axis in 0..3 {
+ assert!(
+ (world[axis][3] - expected_world[axis]).abs() < 0.1,
+ "axis {axis}: actual {} expected {}",
+ world[axis][3],
+ expected_world[axis]
+ );
+ }
+ }
}
#[test]
- fn native_msh_cull_mode_matches_legacy_and_free_flight_camera_projection() {
- let identity = RawCameraTransform {
- words: [
- 1.0_f32.to_bits(),
- 0.0_f32.to_bits(),
- 0.0_f32.to_bits(),
- 0.0_f32.to_bits(),
- 0.0_f32.to_bits(),
- 1.0_f32.to_bits(),
- 0.0_f32.to_bits(),
- 0.0_f32.to_bits(),
- 0.0_f32.to_bits(),
- 0.0_f32.to_bits(),
- 1.0_f32.to_bits(),
- 0.0_f32.to_bits(),
- 0.0_f32.to_bits(),
- 0.0_f32.to_bits(),
- 0.0_f32.to_bits(),
- 1.0_f32.to_bits(),
+ fn ambient_effect_world_refreshes_from_each_component_pose_buffer() {
+ let mut poses = vec![fparkan_animation::Pose::default(); 24];
+ poses[23].translation = [0.0, -68.15999, 21.12];
+ let mut slot = AmbientEffectGpuSlot {
+ kind: AmbientEffectKind::Op3Type2,
+ time_mode: 1,
+ header_flags: 0,
+ random_instance_key: ControlEffectInstanceKey {
+ object_index: 0,
+ component_index: 0,
+ table_index: 8,
+ row_index: 6,
+ },
+ random_seed: 1,
+ phase_jitter: 0.0,
+ random_shift: [0.0; 3],
+ owner_object_index: 0,
+ owner_component_index: 0,
+ owner_node_index: 23,
+ owner_component_is_mounted: false,
+ root_transform: LegacyIron3dEulerTransform {
+ translation: [323.55048, 806.97266, 0.36829287],
+ orientation_radians: [0.0, 0.0, 1.6262363],
+ },
+ owner_scale: [1.0; 3],
+ mount_pose: None,
+ local_from_effect: [
+ [1.0, 0.0, 0.0, -12.964036],
+ [0.0, 1.0, 0.0, 12.991658],
+ [0.0, 0.0, 1.0, -21.119995],
+ [0.0, 0.0, 0.0, 1.0],
],
+ control_source: String::new(),
+ control_table_index: 8,
+ control_row_index: 6,
+ control_point_indices: [104; 3],
+ control_point_owners: [23; 3],
+ command: endpoint_op3_command(),
+ command_index: 0,
+ duration_seconds: 1.0,
+ world_from_effect: [[0.0; 4]; 4],
+ range: EnvironmentGpuRange {
+ range_index: 0,
+ vertex_start: 0,
+ vertex_capacity: 4,
+ material_index: 0,
+ },
+ additional_particle_ranges: Vec::new(),
+ material_animation_index: 0,
+ additional_particle_material_animation_indices: Vec::new(),
+ smoke_emitter_state: None,
+ op7_emitter_state: None,
};
- let projection = LegacyD3d7Projection {
- viewport: [0, 0, 1280, 1024],
- near_plane: 0.5,
- far_plane: 700.0,
- field_of_view_radians: 1.3,
- };
- let legacy = PreviewCameraFrame::from_legacy_d3d7(identity, projection)
- .expect("valid legacy camera projection");
- let free_flight = FreeFlightCamera {
- position: legacy.position,
- yaw: 0.0,
- pitch: 0.0,
- horizontal_fov: 1.3,
- near_plane: 0.5,
- far_plane: 700.0,
- move_speed: 1.0,
- ground_clearance: 0.0,
+ let mut component_pose_buffers = vec![vec![Some(NodePoseBuffer { poses })]];
+ let first = ambient_effect_world_from_component_pose(&slot, &component_pose_buffers)
+ .expect("first sampled owner pose");
+ assert!((first[0][3] - 379.35242).abs() < 0.1 && (first[1][3] - 797.0855).abs() < 0.1);
+
+ let pose = &mut component_pose_buffers[0][0].as_mut().unwrap().poses[23];
+ pose.translation = [
+ pose.translation[0] + 1.0,
+ pose.translation[1] + 2.0,
+ pose.translation[2] + 3.0,
+ ];
+ let second = ambient_effect_world_from_component_pose(&slot, &component_pose_buffers)
+ .expect("next sampled owner pose");
+ slot.world_from_effect = second;
+ assert_ne!(first, slot.world_from_effect);
+ let expected_delta = rotate_unit_vector(
+ [1.0, 2.0, 3.0],
+ [
+ 0.0,
+ 0.0,
+ (1.6262363_f32 * 0.5).sin(),
+ (1.6262363_f32 * 0.5).cos(),
+ ],
+ );
+ for (axis, expected) in expected_delta.into_iter().enumerate() {
+ let delta = slot.world_from_effect[axis][3] - first[axis][3];
+ assert!((delta - expected).abs() < 0.1, "axis {axis}: {delta}");
}
- .preview_frame(1280.0 / 1024.0);
+ }
- for camera in [legacy, free_flight] {
- let front = camera_face_triangle(camera, false);
- let back = [front[0], front[2], front[1]];
- let front_d = dot3(
- sub3(camera.position, front[0]),
- cross3(sub3(front[1], front[0]), sub3(front[2], front[0])),
- );
- let back_d = dot3(
- sub3(camera.position, back[0]),
- cross3(sub3(back[1], back[0]), sub3(back[2], back[0])),
- );
- assert!(front_d > 0.0);
- assert!(back_d < 0.0);
+ #[test]
+ fn mode1_control_effects_hold_active_with_full_opacity_after_duration() {
+ let phase = native_command_phase(
+ 0,
+ ambient_effect_phase(1, 0, 1.25, 1.0).expect("mode 1 phase"),
+ 0.0,
+ );
+ assert_eq!(phase, 1.0);
+ let identity = [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
- let front_vk_area = projected_vulkan_area(camera, front);
- let back_vk_area = projected_vulkan_area(camera, back);
- // Vulkan CLOCKWISE front faces have negative framebuffer area.
- // The native-visible d>0 face is therefore a Vulkan back face.
- assert!(front_vk_area > 0.0, "front area: {front_vk_area}");
- assert!(back_vk_area < 0.0, "back area: {back_vk_area}");
- assert_eq!(native_msh_cull_mode(0, 1), LegacyCullMode::FrontFace);
+ let lamp =
+ evaluate_op3_type2_command(&endpoint_op3_command(), 0, &identity, 1.25, phase, 0)
+ .expect("lamp endpoint");
+ assert!(lamp.active);
+ assert_eq!(lamp.color[3], 1.0);
- let mirrored_front = camera_face_triangle(camera, true);
- let mirrored_d = dot3(
- sub3(camera.position, mirrored_front[0]),
- cross3(
- sub3(mirrored_front[1], mirrored_front[0]),
- sub3(mirrored_front[2], mirrored_front[0]),
- ),
+ let op9 = endpoint_gener_ball_command(FxOpcode::Op9);
+ let generated = evaluate_f_gener_ball_mesh(
+ &op9,
+ 0,
+ &identity,
+ phase,
+ 1.25,
+ [0.0, 0.0, 500.0],
+ AMBIENT_EFFECT_NATIVE_CATEGORY,
+ )
+ .expect("generator sphere endpoint");
+ assert!(generated.active);
+ assert_eq!(generated.color[3], 1.0);
+ assert_eq!(generated.vertices.len(), 88);
+ assert_eq!(generated.indices.len(), 120);
+ assert_eq!(generated.native_category, 0);
+
+ let op4 = endpoint_gener_ball_command(FxOpcode::Op4);
+ let orb = evaluate_f_gener_ball(&op4, 0, &identity, phase, 1.25, [0.0, 0.0, 500.0])
+ .expect("opcode 4 billboard endpoint");
+ assert!(orb.active);
+ assert_eq!(orb.color[3], 1.0);
+ }
+
+ #[test]
+ fn op9_mesh_bridge_allocates_one_range_per_quad_and_cap() {
+ let command = endpoint_gener_ball_command(FxOpcode::Op9);
+ let identity = [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ let frame = evaluate_f_gener_ball_mesh(
+ &command,
+ 0,
+ &identity,
+ 0.5,
+ 0.5,
+ [0.0, 0.0, 500.0],
+ AMBIENT_EFFECT_NATIVE_CATEGORY,
+ )
+ .expect("category-zero Op9 mesh");
+ let primitives = ambient_effect_mesh_primitive_layout(&frame)
+ .expect("native Op9 quad/cap primitive layout");
+ assert_eq!(primitives.len(), 24);
+ assert_eq!(
+ primitives
+ .iter()
+ .filter(|item| item.vertex_count == 4)
+ .count(),
+ 16
+ );
+ assert_eq!(
+ primitives
+ .iter()
+ .filter(|item| item.vertex_count == 3)
+ .count(),
+ 8
+ );
+
+ let mut mesh = VulkanStaticMesh {
+ vertices: Vec::new(),
+ indices: Vec::new(),
+ draw_ranges: Vec::new(),
+ };
+ let ranges = primitives
+ .iter()
+ .copied()
+ .map(|primitive| {
+ let range = append_environment_range(
+ &mut mesh,
+ 0,
+ ambient_effect_mesh_primitive_vertices(&frame, primitive),
+ ambient_effect_mesh_primitive_indices(&frame, primitive)
+ .expect("primitive-local indices"),
+ LegacyPipelineState::default(),
+ )
+ .expect("allocate one native primitive range");
+ write_ambient_effect_mesh_primitive(&mut mesh, &range, &frame, primitive)
+ .expect("write native primitive frame");
+ range
+ })
+ .collect::<Vec<_>>();
+
+ assert_eq!(ranges.len(), 24);
+ assert_eq!(mesh.draw_ranges.len(), 24);
+ assert_eq!(mesh.indices.len(), 120);
+ assert!(mesh.draw_ranges.iter().all(|range| {
+ range.alpha_test_reference == VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE
+ }));
+ assert_eq!(
+ mesh.draw_ranges
+ .iter()
+ .map(|range| range.index_count)
+ .sum::<u32>(),
+ 120
+ );
+ for (range, primitive) in ranges.iter().zip(primitives) {
+ let vertices =
+ &mesh.vertices[range.vertex_start..range.vertex_start + range.vertex_capacity];
+ assert_eq!(range.vertex_capacity, primitive.vertex_count);
+ for (offset, vertex) in vertices.iter().enumerate() {
+ assert_eq!(
+ vertex.position,
+ frame.vertices[primitive.vertex_start + offset]
+ );
+ assert_eq!(vertex.uv, frame.uv[primitive.vertex_start + offset]);
+ assert_eq!(vertex.overlay_alpha, frame.color[3]);
+ }
+ let range_indices = &mesh.draw_ranges[range.range_index];
+ assert_eq!(range_indices.index_count as usize, primitive.index_count);
+ let first_index = range_indices.first_index as usize;
+ assert_eq!(
+ mesh.indices[first_index],
+ u32::try_from(range.vertex_start).unwrap()
);
- assert!(mirrored_d < 0.0);
- assert!(projected_vulkan_area(camera, mirrored_front) < 0.0);
- assert_eq!(native_msh_cull_mode(0, -1), LegacyCullMode::BackFace);
+ let camera = [10.0, -5.0, 2.0];
+ let expected = sub3(frame.vertices[primitive.vertex_start], camera);
+ let expected = dot3(expected, expected).sqrt();
+ let actual = first_indexed_vertex_distance(&mesh, range.range_index, camera)
+ .expect("native Op9 primitive distance key");
+ assert!((actual - expected).abs() < 1e-5);
+ }
+
+ for selector in [1, 2] {
+ let mut command = command.clone();
+ write_effect_u32(Arc::make_mut(&mut command.raw_body), 0xC4, selector);
+ let frame = evaluate_f_gener_ball_mesh(
+ &command,
+ 0,
+ &identity,
+ 0.5,
+ 0.5,
+ [0.0, 0.0, 500.0],
+ AMBIENT_EFFECT_NATIVE_CATEGORY,
+ )
+ .expect("Op9 selector frame");
+ assert!(!ambient_effect_mesh_primitive_layout(&frame)
+ .expect("selector native quad/cap layout")
+ .is_empty());
}
}
#[test]
- fn native_msh_handedness_uses_full_placement_transform_and_handles_singular_scale() {
- let rotated = LegacyIron3dEulerTransform {
- translation: [8.0, -4.0, 3.0],
- orientation_radians: [0.4, -0.7, 1.2],
- };
- assert_eq!(native_msh_handedness(rotated, [2.0, 3.0, 4.0]), 1);
- assert_eq!(native_msh_handedness(rotated, [-2.0, 3.0, 4.0]), -1);
- assert_eq!(native_msh_handedness(rotated, [0.0, 3.0, 4.0]), 0);
- assert_eq!(native_msh_cull_mode(0, 0), LegacyCullMode::Disabled);
+ fn mode2_control_effects_repeat_and_reset_at_header_duration() {
+ assert_eq!(ambient_effect_phase(2, 0, 1.0, 2.0), Some(0.5));
+ assert_eq!(ambient_effect_phase(2, 0, 2.0, 2.0), Some(0.0));
+ assert_eq!(ambient_effect_phase(2, 0, 2.25, 2.0), Some(0.125));
+ assert_eq!(ambient_effect_phase(1, 0, 2.0, 2.0), Some(1.0));
+ assert_eq!(ambient_effect_phase(0, 0, 0.5, 0.0), Some(0.0));
+ assert_eq!(ambient_effect_phase(3, 0, 0.5, 2.0), None);
+ assert_eq!(ambient_effect_phase(2, 0x20, 0.5, 1.0), Some(1.0));
+ assert_eq!(ambient_effect_phase(2, 0x20, 0.75, 1.0), Some(0.5));
+ assert_eq!(ambient_effect_phase(1, 0x200, 0.5, 1.0), Some(0.25));
+ assert_eq!(ambient_effect_phase(1, 0x220, 0.5, 1.0), Some(0.5));
+ assert_eq!(ambient_effect_phase(1, 0x220, 1.5, 1.0), Some(-2.5));
+ assert_eq!(ambient_effect_phase(1, 0x20, 1.5, 1.0), Some(-1.0));
}
#[test]
- fn native_msh_4000_samples_only_first_triangle_and_raw_two_sided_bypasses_it() {
- let front = native_front_triangle();
- let back = front.map(|point| [-point[0], point[1], point[2]]);
- let front_then_back = [front[0], front[1], front[2], back[0], back[1], back[2]];
- let (mesh, range) = facing_mesh(0x4000, &front_then_back);
- assert!(native_msh_first_triangle_visible(
- &mesh, &range, [0.0; 3], 1
- ));
+ fn control_lamp_accepts_only_supported_bodies_and_keeps_mat0_state() {
+ assert_eq!(
+ ambient_effect_command_kind(&endpoint_op3_command()),
+ Some(AmbientEffectKind::Op3Type2)
+ );
+ let mut type0 = endpoint_op3_command();
+ write_effect_u32(Arc::make_mut(&mut type0.raw_body), 0, 0);
+ assert_eq!(
+ ambient_effect_command_kind(&type0),
+ Some(AmbientEffectKind::Op3Type0)
+ );
+ assert!(ambient_effect_render_selector_allows_header(0x10, 0));
+ assert!(!ambient_effect_render_selector_allows_header(0x800, 0));
+ assert!(!ambient_effect_render_selector_allows_header(0xD00, 0));
+ assert!(ambient_effect_render_selector_allows_header(0xD00, 1));
+ assert!(ambient_effect_render_selector_allows_header(0xD00, 2));
+ for opcode in [FxOpcode::Op9, FxOpcode::Op4] {
+ assert_eq!(
+ ambient_effect_command_kind(&endpoint_gener_ball_command(opcode)),
+ Some(AmbientEffectKind::GenerBall),
+ "{opcode:?}"
+ );
+ }
+ for selector in [1, 2] {
+ let mut op9 = endpoint_gener_ball_command(FxOpcode::Op9);
+ write_effect_u32(Arc::make_mut(&mut op9.raw_body), 0xC4, selector);
+ assert_eq!(
+ ambient_effect_command_kind(&op9),
+ Some(AmbientEffectKind::GenerBall),
+ "Op9 selector {selector}"
+ );
+ }
+ let mut invalid_op9 = endpoint_gener_ball_command(FxOpcode::Op9);
+ write_effect_u32(Arc::make_mut(&mut invalid_op9.raw_body), 0xC4, 3);
+ assert_eq!(ambient_effect_command_kind(&invalid_op9), None);
+ assert_eq!(
+ ambient_effect_command_kind(&endpoint_op8_command(6)),
+ Some(AmbientEffectKind::SmokePool)
+ );
+ for pool_count in [0, 4097] {
+ assert_eq!(
+ ambient_effect_command_kind(&endpoint_op8_command(pool_count)),
+ None
+ );
+ }
+ for raw_mode in [0, 1] {
+ let op7 = endpoint_op7_command(raw_mode, 1.5, 0.0, 3, 2);
+ assert_eq!(
+ ambient_effect_command_kind(&op7),
+ Some(AmbientEffectKind::Op7ParticlePool),
+ "raw mode {raw_mode}"
+ );
+ assert_eq!(ambient_effect_op7_range_capacity(&op7), Some(6));
+ }
+ let accelerated_op7 = endpoint_op7_command(1, 1.5, 0.25, 3, 2);
+ assert_eq!(
+ ambient_effect_command_kind(&accelerated_op7),
+ Some(AmbientEffectKind::Op7ParticlePool)
+ );
+ assert_eq!(ambient_effect_op7_range_capacity(&accelerated_op7), Some(6));
+ for unsupported in [
+ endpoint_op7_command(2, 1.5, 0.0, 3, 2),
+ endpoint_op7_command(0, 1.5, f32::NAN, 3, 2),
+ endpoint_op7_command(0, 0.0, 0.0, 3, 2),
+ endpoint_op7_command(0, f32::NAN, 0.0, 3, 2),
+ endpoint_op7_command(0, 1.5, 0.0, u32::MAX, 2),
+ ] {
+ assert_eq!(ambient_effect_command_kind(&unsupported), None);
+ }
- let back_then_front = [back[0], back[1], back[2], front[0], front[1], front[2]];
- let (mesh, range) = facing_mesh(0x4000, &back_then_front);
- assert!(!native_msh_first_triangle_visible(
- &mesh, &range, [0.0; 3], 1
- ));
+ let state = ambient_effect_pipeline_state(2).expect("native additive MAT0 category");
+ assert_eq!(state.blend, LegacyBlendMode::Additive);
+ assert!(state.alpha_test);
+ assert_eq!(state.depth, LegacyDepthMode::TestReadOnly);
+ }
- let (mesh, range) = facing_mesh(0x4002, &back_then_front);
- assert!(native_msh_first_triangle_visible(
- &mesh, &range, [0.0; 3], 1
- ));
+ #[test]
+ fn op1_control_lights_use_native_phase_owner_transform_and_flags() {
+ let mut slot = ControlPointLightSlot {
+ time_mode: 1,
+ header_flags: 0,
+ random_instance_key: ControlEffectInstanceKey {
+ object_index: 0,
+ component_index: 0,
+ table_index: 0,
+ row_index: 0,
+ },
+ random_seed: 1,
+ phase_jitter: 0.0,
+ random_shift: [0.0; 3],
+ duration_seconds: 1.0,
+ owner_object_index: 0,
+ owner_component_index: 0,
+ owner_node_index: 0,
+ owner_component_is_mounted: false,
+ root_transform: LegacyIron3dEulerTransform {
+ translation: [10.0, 20.0, 30.0],
+ orientation_radians: [0.0; 3],
+ },
+ owner_scale: [1.0; 3],
+ mount_pose: None,
+ local_from_effect: [
+ [1.0, 0.0, 0.0, 1.0],
+ [0.0, 1.0, 0.0, 2.0],
+ [0.0, 0.0, 1.0, 3.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ],
+ world_from_effect: [[0.0; 4]; 4],
+ command_index: 0,
+ command: endpoint_op1_command(6, 0.5, 1.0),
+ };
+ let poses = vec![vec![None]];
+ let mounts = vec![vec![None]];
+
+ let inactive = slot.clone();
+ let random_samples = HashMap::new();
+ assert!(update_control_point_lights(
+ &mut [inactive],
+ &poses,
+ &mounts,
+ 0.25,
+ &random_samples,
+ false,
+ )
+ .is_empty());
+ let lights = update_control_point_lights(
+ std::slice::from_mut(&mut slot),
+ &poses,
+ &mounts,
+ 0.75,
+ &random_samples,
+ false,
+ );
+ assert_eq!(lights.len(), 1);
+ let (light, flags) = lights[0];
+ assert_eq!(light.position, [13.0, 22.0, 33.0]);
+ assert_eq!(light.rgb, [0.25, 0.5, 0.75]);
+ assert_eq!(light.range, 15.0);
+ assert_eq!(light.coefficients, [1.0, 0.5, 0.25]);
+ assert_eq!(flags, 0xA000_0000);
assert_eq!(
- native_msh_cull_mode(range.batch_flags, 1),
- LegacyCullMode::Disabled
+ [
+ slot.world_from_effect[0][3],
+ slot.world_from_effect[1][3],
+ slot.world_from_effect[2][3]
+ ],
+ [11.0, 22.0, 33.0]
+ );
+
+ slot.header_flags = 0x29;
+ slot.random_seed = 0x1234_5678;
+ slot.phase_jitter = 0.4;
+ slot.random_shift = [2.0, 4.0, 6.0];
+ let sibling_slots = [slot.clone(), slot.clone()];
+ let mut states = HashMap::new();
+ let first = advance_control_effect_random_states(
+ &mut states,
+ &[],
+ &sibling_slots,
+ SceneSampleId::Replay(7),
+ 150,
+ 1.5,
+ );
+ assert_eq!(first.len(), 1);
+ let key = slot.random_instance_key;
+ let first_sample = first[&key];
+ assert_eq!(first_sample.phase_jitter, 0.0, "the -1 phase skips jitter");
+ assert_ne!(first_sample.accumulated_shift, [0.0; 3]);
+ let mut expected_state = ControlEffectRandomState::new(
+ slot.random_seed,
+ slot.header_flags,
+ slot.phase_jitter,
+ slot.random_shift,
+ );
+ assert_eq!(
+ first_sample,
+ expected_state.advance(SceneSampleId::Replay(7), 150, -1.0)
+ );
+ assert_eq!(
+ advance_control_effect_random_states(
+ &mut states,
+ &[],
+ &sibling_slots,
+ SceneSampleId::Replay(7),
+ 150,
+ 1.5,
+ )[&key],
+ first_sample,
+ "a repeated read of the same captured update reuses the shared sample"
+ );
+ let second = advance_control_effect_random_states(
+ &mut states,
+ &[],
+ &sibling_slots,
+ SceneSampleId::Replay(8),
+ 200,
+ 0.5,
+ );
+ assert_eq!(
+ second[&key],
+ expected_state.advance(SceneSampleId::Replay(8), 200, 1.0)
+ );
+ assert_ne!(second[&key].phase_jitter, first_sample.phase_jitter);
+ assert_ne!(
+ second[&key].accumulated_shift,
+ first_sample.accumulated_shift
+ );
+ assert_eq!(states[&key].last_update_clock_ms, Some(200));
+
+ let negative_non_sentinel =
+ ambient_effect_phase(1, 0x220, 1.5, 1.0).expect("post-duration transformed phase");
+ assert_eq!(negative_non_sentinel, -2.5);
+ let mut negative_state = ControlEffectRandomState::new(
+ slot.random_seed,
+ 0x229,
+ slot.phase_jitter,
+ slot.random_shift,
+ );
+ assert_eq!(
+ native_command_phase(0x229, negative_non_sentinel, 0.4),
+ 0.0,
+ "a phase below -1 clamps to zero rather than using the -1 sentinel"
+ );
+ assert_ne!(
+ negative_state
+ .advance(SceneSampleId::Live(1), 150, negative_non_sentinel)
+ .phase_jitter,
+ 0.0,
+ "a non-sentinel negative phase still consumes the jitter sample"
);
}
#[test]
- fn world_draw_range_count_restores_after_alpha_and_msh_facing_changes() {
- let triangle = native_front_triangle();
- let (mesh, range) = facing_mesh(0x4000, &triangle);
- let original_count = range.index_count;
+ fn op1_generic_diffuse_list_excludes_bit31_and_clears_inactive_lights() {
+ let light = |position, active| VulkanPointLight {
+ position,
+ rgb: [0.25, 0.5, 0.75],
+ range: 15.0,
+ coefficients: [1.0, 0.5, 0.25],
+ active,
+ };
+ let raw_records = vec![
+ (light([1.0, 2.0, 3.0], true), 0xA000_0000),
+ (light([4.0, 5.0, 6.0], true), 0x2000_0000),
+ (light([7.0, 8.0, 9.0], true), 0x8000_0000),
+ (light([10.0, 11.0, 12.0], false), 0x0000_0000),
+ ];
+ let ordinary = ordinary_control_point_lights(&raw_records);
+ assert_eq!(ordinary.len(), 1);
+ assert_eq!(ordinary[0].position, [4.0, 5.0, 6.0]);
+ assert_eq!(raw_records.len(), 4);
assert_eq!(
- world_draw_range_index_count(&mesh, &range, original_count, 0.0, [0.0; 3], Some(1)),
- 0
+ raw_records
+ .iter()
+ .map(|(_, flags)| *flags)
+ .collect::<Vec<_>>(),
+ vec![0xA000_0000, 0x2000_0000, 0x8000_0000, 0]
);
+
+ assert!(ordinary_control_point_lights(&[]).is_empty());
+ assert!(
+ ordinary_control_point_lights(&[(light([13.0, 14.0, 15.0], false), 0x2000_0000)])
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn op1_control_slot_accepts_supported_bodies_with_instance_random_flags() {
+ let command = endpoint_op1_command(6, 0.0, 1.0);
+ assert!(ambient_effect_op1_supported(&command));
+ assert!(!ambient_effect_op1_supported(&endpoint_op1_command(
+ 4, 0.0, 1.0
+ )));
+ }
+
+ #[test]
+ fn op1_phase_preserves_native_sentinel_and_initial_manager_gate() {
+ assert_eq!(native_command_phase(0, -1.0, 0.4), -1.0);
+ assert_eq!(native_command_phase(0x100, 0.625, 0.0), 0.625);
+ assert_eq!(native_command_phase(0x80, 0.625, 0.4), 0.0);
+ assert_eq!(native_command_phase(0x81, -1.0, 0.4), 0.4);
+ assert_eq!(native_command_phase(0, -0.25, 0.0), 0.0);
+ assert_eq!(native_command_phase(0, 1.25, 0.0), 1.0);
+ assert_eq!(native_command_phase(1, 0.5, 0.25), 0.75);
+ assert_eq!(native_command_phase(1, 0.9, 0.25), 1.0);
+ let flags_200_20 =
+ ambient_effect_phase(1, 0x220, 1.5, 1.0).expect("triangular post-duration phase");
+ assert_eq!(flags_200_20, -2.5);
+ assert_eq!(native_command_phase(0x220, flags_200_20, 0.4), 0.0);
+ let raw_minus_one =
+ ambient_effect_phase(1, 0x20, 1.5, 1.0).expect("triangular sentinel phase");
+ assert_eq!(raw_minus_one, -1.0);
+ assert_eq!(native_command_phase(0x21, raw_minus_one, 0.4), -1.0);
+ assert_eq!(native_command_phase(0xA1, raw_minus_one, 0.4), 0.4);
+ }
+
+ #[test]
+ fn op7_variable_frames_clear_inactive_draw_ranges() {
+ let aligned = align_ambient_effect_frames(
+ vec![endpoint_effect_frame(3), endpoint_effect_frame(7)],
+ 4,
+ true,
+ )
+ .expect("variable Op7 output fits its fixed range allocation");
+ assert_eq!(aligned.len(), 4);
assert_eq!(
- world_draw_range_index_count(
- &mesh,
- &range,
- original_count,
- 1.0,
- [0.0, 0.0, -2.0],
- Some(1),
- ),
- 0
+ aligned[0].as_ref().map(|frame| frame.command_index),
+ Some(3)
);
assert_eq!(
- world_draw_range_index_count(&mesh, &range, original_count, 1.0, [0.0; 3], Some(1)),
- original_count
+ aligned[1].as_ref().map(|frame| frame.command_index),
+ Some(7)
);
+ assert!(aligned[2].is_none() && aligned[3].is_none());
+
+ assert!(align_ambient_effect_frames(vec![endpoint_effect_frame(0)], 4, false,).is_err());
+ assert!(align_ambient_effect_frames(vec![endpoint_effect_frame(0); 5], 4, true,).is_err());
+ }
+
+ #[test]
+ fn active_control_fx_vertex_color_uses_sampled_mat0_additive_rgb() {
+ let mut slot = AmbientEffectGpuSlot {
+ kind: AmbientEffectKind::Op3Type2,
+ time_mode: 1,
+ header_flags: 0,
+ random_instance_key: ControlEffectInstanceKey {
+ object_index: 0,
+ component_index: 0,
+ table_index: 0,
+ row_index: 0,
+ },
+ random_seed: 1,
+ phase_jitter: 0.0,
+ random_shift: [0.0; 3],
+ owner_object_index: 0,
+ owner_component_index: 0,
+ owner_node_index: 0,
+ owner_component_is_mounted: false,
+ root_transform: LegacyIron3dEulerTransform {
+ translation: [0.0; 3],
+ orientation_radians: [0.0; 3],
+ },
+ owner_scale: [1.0; 3],
+ mount_pose: None,
+ local_from_effect: [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ],
+ control_source: String::new(),
+ control_table_index: 0,
+ control_row_index: 0,
+ control_point_indices: [0; 3],
+ control_point_owners: [0; 3],
+ command: endpoint_op3_command(),
+ command_index: 0,
+ duration_seconds: 1.0,
+ world_from_effect: [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ],
+ range: EnvironmentGpuRange {
+ range_index: 0,
+ vertex_start: 0,
+ vertex_capacity: 4,
+ material_index: 0,
+ },
+ additional_particle_ranges: vec![EnvironmentGpuRange {
+ range_index: 1,
+ vertex_start: 4,
+ vertex_capacity: 4,
+ material_index: 1,
+ }],
+ material_animation_index: 0,
+ additional_particle_material_animation_indices: vec![1],
+ smoke_emitter_state: None,
+ op7_emitter_state: None,
+ };
assert_eq!(
- world_draw_range_index_count(
- &mesh,
- &range,
- original_count,
- 1.0,
- [0.0, 0.0, -2.0],
- None,
- ),
- original_count
+ ambient_effect_ranges(&slot)
+ .iter()
+ .map(|(range, binding)| (range.range_index, *binding))
+ .collect::<Vec<_>>(),
+ vec![(0, 0), (1, 1)]
);
+ let mut mesh = VulkanStaticMesh {
+ vertices: vec![environment_vertex([0.0; 3], [1.0; 3], [0.0; 2]); 8],
+ indices: Vec::new(),
+ draw_ranges: Vec::new(),
+ };
+ let updates = [
+ AmbientEffectGeometryUpdate {
+ range_index: 0,
+ index_count: 6,
+ command_rgb: [1.0; 3],
+ },
+ AmbientEffectGeometryUpdate {
+ range_index: 1,
+ index_count: 6,
+ command_rgb: [1.0; 3],
+ },
+ ];
+ let mut active_materials = HashMap::from([
+ (
+ 0,
+ ActiveMaterialState {
+ material_index: 0,
+ phase_index: 0,
+ diffuse_alpha: 1.0,
+ additive_rgb: [0.9882353, 1.0, 0.16862746],
+ },
+ ),
+ (
+ 1,
+ ActiveMaterialState {
+ material_index: 1,
+ phase_index: 0,
+ diffuse_alpha: 1.0,
+ additive_rgb: [0.9882353, 1.0, 0.16862746],
+ },
+ ),
+ ]);
+
+ apply_ambient_effect_material_tints(
+ &[slot.clone()],
+ &mut mesh,
+ &updates,
+ &active_materials,
+ )
+ .expect("apply sampled yellow MAT0 color");
+ assert!(mesh
+ .vertices
+ .iter()
+ .all(|vertex| vertex.color == [0.9882353, 1.0, 0.16862746]));
+
+ for material in active_materials.values_mut() {
+ material.additive_rgb = [0.1, 0.7, 0.2];
+ }
+ slot.kind = AmbientEffectKind::SmokePool;
+ apply_ambient_effect_material_tints(&[slot], &mut mesh, &updates, &active_materials)
+ .expect("apply next sampled MAT0 color");
+ assert!(mesh
+ .vertices
+ .iter()
+ .all(|vertex| vertex.color == [0.1, 0.7, 0.2]));
+ }
+
+ #[test]
+ fn ambient_fx_uses_vertex_opacity_without_multiplying_mat0_opacity() {
+ let mat0_opacity = ambient_effect_material_alpha(true, 0.4);
+ let effect_opacity = 0.5;
+ let texture_alpha = 0.25;
+ assert_eq!(mat0_opacity, 1.0);
+ assert_eq!(mat0_opacity * effect_opacity, 0.5);
+ assert_eq!(texture_alpha * mat0_opacity * effect_opacity, 0.125);
+ assert_eq!(ambient_effect_material_alpha(false, 0.4), 0.4);
+ }
+
+ #[test]
+ fn material_animation_cpu_packet_uses_sampled_phase_without_renderer() {
+ let phase = |additive_red| MaterialPhase {
+ parameters: [
+ additive_red,
+ 0,
+ 0,
+ 100,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ ],
+ texture_raw: [0; 16],
+ };
+ let document = Mat0Document {
+ version: 0,
+ animation_block_count: 1,
+ phases: vec![phase(0), phase(255)],
+ prefix: Vec::new(),
+ header_opaque: [0; 2],
+ animation_blocks: vec![MaterialAnimationBlock {
+ header_raw: 0,
+ keys: vec![
+ MaterialKey {
+ k0: 0,
+ k1: 500,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 1,
+ k1: 1000,
+ k2: 0,
+ },
+ ],
+ bytes: Vec::new(),
+ }],
+ };
+ let mut binding = MaterialAnimationBinding {
+ initial_phase_index: 0,
+ normalized_phase_override: Some(0.75),
+ ambient_fx_material_alpha: true,
+ variants: Vec::new(),
+ base: MaterialPhaseBinding {
+ document,
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: 0,
+ frozen_phase_index: None,
+ phase_uv_transforms: vec![[0.0, 0.0, 1.0, 1.0]; 2],
+ },
+ detail: None,
+ overlay: None,
+ overlay_detail: None,
+ stage_textures: None,
+ };
+
+ let effect_sample = sample_material_animation_base(&mut binding, 0)
+ .expect("sample the effect's native normalized MAT0 phase");
+ assert_eq!(effect_sample.phase_index, 1);
+ assert_eq!(effect_sample.coefficients.additive_rgb, [1.0, 0.0, 0.0]);
+
+ binding.normalized_phase_override = None;
+ let world_sample = sample_material_animation_base(&mut binding, 0)
+ .expect("sample ordinary clock-driven material phase");
+ assert_eq!(world_sample.phase_index, 0);
+ assert_eq!(world_sample.coefficients.additive_rgb, [0.0, 0.0, 0.0]);
+
+ binding.variants.push(MaterialVariantSet {
+ phase_material_indices: vec![0, 0],
+ range_indices: vec![7],
+ pipeline_category: 0,
+ });
+ let mut materials = Vec::new();
+ append_environment_gradient_material(&mut materials)
+ .expect("prepare test material without a renderer");
+ let update =
+ sample_material_animations(std::slice::from_mut(&mut binding), &materials, 500)
+ .expect("produce a CPU material-frame packet without a renderer");
+ assert_eq!(update.active_materials[&7].material_index, 0);
+ assert_eq!(update.active_materials[&7].phase_index, 1);
+ assert_eq!(update.range_selections[0].range_index, 7);
+ }
+
+ #[test]
+ fn ambient_fx_tint_requires_a_proven_ambient_mat0_phase() {
+ assert!(ambient_effect_tint_supported(
+ &[false, false],
+ &[[0.0; 3], [0.2; 3]],
+ &[[1.0, 1.0, 1.0], [0.2, 0.4, 0.8]],
+ ));
+ assert!(!ambient_effect_tint_supported(&[], &[], &[]));
+ assert!(!ambient_effect_tint_supported(&[false], &[], &[]));
+ assert!(!ambient_effect_tint_supported(
+ &[true],
+ &[[0.0; 3]],
+ &[[1.0; 3]]
+ ));
+ assert!(!ambient_effect_tint_supported(
+ &[false],
+ &[[0.1, 0.0, 0.0]],
+ &[[0.0; 3]],
+ ));
+ assert!(!ambient_effect_tint_supported(
+ &[false],
+ &[[0.0; 3]],
+ &[[f32::NAN, 1.0, 1.0]]
+ ));
+ }
+
+ #[test]
+ fn ambient_fx_ranges_are_excluded_from_world_alpha_and_count_updates() {
+ let fixed_ranges = HashSet::from([1, 4, 7]);
+ let world_ranges = world_draw_range_indices(9, &fixed_ranges);
+ assert_eq!(world_ranges, vec![0, 2, 3, 5, 6, 8]);
+ assert!(!world_ranges.contains(&7));
+ }
+
+ #[test]
+ fn ambient_control_rows_accept_unconditional_low_words_but_skip_state_gates_and_chains() {
+ let mut row = PreparedControlEffectRow {
+ table_index: 2,
+ row_index: 0,
+ raw_condition: [0, 7, 9],
+ point_offsets: [0, 1, 2],
+ selector: 0,
+ resource: fparkan_assets::PreparedControlResourcePair {
+ archive: [0; 32],
+ name: [0; 32],
+ },
+ };
+ assert!(ambient_effect_row_unconditionally_active(&row));
+ for condition_flag in [0x1000_0000, 0x2000_0000, 0x4000_0000, 0x8000_0000] {
+ row.raw_condition[0] = condition_flag;
+ assert!(!ambient_effect_row_unconditionally_active(&row));
+ }
}
}
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
index 2ee829f..a1ae4ec 100644
--- a/crates/fparkan-assets/src/lib.rs
+++ b/crates/fparkan-assets/src/lib.rs
@@ -10,8 +10,8 @@ pub use fparkan_material::{
sample_material_normalized_phase, sample_material_phase, Mat0Document, MaterialCoefficients,
MaterialError, MaterialPhase, MaterialPhaseSample,
};
-use fparkan_mission_format::{decode_tma, decode_tma_land_path, ClanBody};
-pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
+use fparkan_mission_format::{decode_tma, decode_tma_land_path};
+pub use fparkan_mission_format::{ClanBody, LpString, MissionDocument, MissionError, TmaProfile};
use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError};
use fparkan_nres::{decode as decode_nres, ReadProfile};
pub use fparkan_nres::{NresDocument, NresError};
@@ -28,6 +28,11 @@ pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError};
pub use fparkan_texm::{PageRect, RgbaImage};
use fparkan_vfs::DirectoryVfs;
+mod research;
+pub use research::{
+ calc_full_research_cost, ResearchCandidate, ResearchCandidateSet, ResearchCost, ResearchError,
+ ResearchTrfDocument,
+};
use std::collections::{hash_map::Entry, HashMap};
use std::fmt;
use std::hash::{Hash, Hasher};
@@ -1905,16 +1910,12 @@ impl MissionAssets {
visual_count: self.visuals.len(),
model_count: self.models.len(),
material_count: self.materials.len(),
- texture_count: self
- .textures
- .iter()
- .filter(|texture| texture.usage == PreparedTextureUsage::Diffuse)
- .count(),
+ texture_count: self.visuals.iter().map(|visual| visual.texture_count).sum(),
lightmap_count: self
- .textures
+ .visuals
.iter()
- .filter(|texture| texture.usage == PreparedTextureUsage::Lightmap)
- .count(),
+ .map(|visual| visual.lightmap_count)
+ .sum(),
}
}
}
@@ -2111,6 +2112,33 @@ impl<R: ResourceRepository> AssetManager<R> {
) -> Result<MissionAssetPlan, AssetError> {
build_mission_asset_plan_with_repository(&self.repository, prototypes)
}
+
+ /// Reads raw opcodes from the validated initial Control effect group.
+ ///
+ /// A complete no-effect section or an out-of-range raw initial-group
+ /// selector returns `Some(Vec::new())`; unsupported shapes return `None`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AssetError`] when the Control resource is malformed.
+ pub fn initial_control_effect_opcodes(
+ &self,
+ source: &ResourceKey,
+ model_node_count: usize,
+ prototype_index: usize,
+ ) -> Result<Option<Vec<u32>>, AssetError> {
+ let Some(payload) = read_optional_key(&self.repository, source, Some("component control"))?
+ else {
+ return Ok(None);
+ };
+ let (_, opcodes) = decode_component_control_payload_with_opcodes(
+ &payload,
+ source.clone(),
+ model_node_count,
+ prototype_index,
+ )?;
+ Ok(opcodes)
+ }
}
/// Produces a count-only plan from a prototype graph.
@@ -3866,7 +3894,7 @@ fn control_i32_at(payload: &[u8], offset: usize) -> Option<i32> {
}
const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] =
- &[1, 2, 3, 4, 5, 8, 9, 10, 12, 13, 15, 19, 21, 25, 26, 27];
+ &[1, 2, 3, 4, 5, 8, 9, 10, 12, 13, 15, 17, 19, 21, 25, 26, 27];
const CONTROL_TYPED_RECORD_FIXED_BYTES: usize = 0xB0;
const CONTROL_TYPED_RECORD_COUNT_OFFSET: usize = 0xAC;
const CONTROL_TYPED_RECORD_LIMIT: usize = 4096;
@@ -3880,6 +3908,7 @@ const CONTROL_EFFECT_ROW_BYTES: usize = 100;
struct DecodedComponentEffectSection {
section: PreparedControlEffectSection,
initial_effect_group: Option<usize>,
+ initial_group_opcodes: Option<Vec<u32>>,
typed_records: Vec<PreparedControlTypedRecord>,
}
@@ -3904,6 +3933,7 @@ where
}
},
initial_effect_group: None,
+ initial_group_opcodes: (cursor == payload.len()).then(Vec::new),
typed_records: Vec::new(),
});
}
@@ -3925,6 +3955,7 @@ where
typed_record_tag: Some(tag),
},
initial_effect_group: None,
+ initial_group_opcodes: None,
typed_records,
});
}
@@ -4014,8 +4045,9 @@ where
}
if effect_group_count == 0 {
+ let complete = cursor == payload.len();
return Ok(DecodedComponentEffectSection {
- section: if cursor == payload.len() {
+ section: if complete {
PreparedControlEffectSection::Parsed(Vec::new())
} else {
PreparedControlEffectSection::Unsupported {
@@ -4024,6 +4056,7 @@ where
}
},
initial_effect_group: None,
+ initial_group_opcodes: complete.then(Vec::new),
typed_records,
});
}
@@ -4042,6 +4075,7 @@ where
.filter(|group| *group < effect_group_count);
cursor = group_header_end;
+ let mut initial_group_opcodes = Some(Vec::new());
let mut effect_rows = Vec::new();
for table_index in 0..effect_group_count {
let row_count = usize::try_from(
@@ -4068,7 +4102,14 @@ where
for row_index in 0..row_count {
let row_start = rows_start + row_index * CONTROL_EFFECT_ROW_BYTES;
let row = &payload[row_start..row_start + CONTROL_EFFECT_ROW_BYTES];
- if control_u32_at(row, 0x0C) != Some(4) {
+ let opcode = control_u32_at(row, 0x0C).expect("fixed effect row has opcode");
+ if initial_effect_group == Some(table_index) {
+ initial_group_opcodes
+ .as_mut()
+ .expect("valid initial group has an opcode list")
+ .push(opcode);
+ }
+ if opcode != 4 {
continue;
}
let raw_condition = [
@@ -4107,12 +4148,14 @@ where
typed_record_tag: None,
},
initial_effect_group: None,
+ initial_group_opcodes: None,
typed_records,
});
}
Ok(DecodedComponentEffectSection {
section: PreparedControlEffectSection::Parsed(effect_rows),
initial_effect_group,
+ initial_group_opcodes,
typed_records,
})
}
@@ -4129,6 +4172,21 @@ fn decode_component_control_payload(
model_node_count: usize,
prototype_index: usize,
) -> Result<PreparedComponentControl, AssetError> {
+ decode_component_control_payload_with_opcodes(
+ payload,
+ source,
+ model_node_count,
+ prototype_index,
+ )
+ .map(|(control, _)| control)
+}
+
+fn decode_component_control_payload_with_opcodes(
+ payload: &[u8],
+ source: ResourceKey,
+ model_node_count: usize,
+ prototype_index: usize,
+) -> Result<(PreparedComponentControl, Option<Vec<u32>>), AssetError> {
const HEADER_BYTES: usize = 5 * 4;
const FIXED_SECTION_BYTES: usize = 108;
const SECTION_FIXED_BYTES: usize = 156;
@@ -4266,18 +4324,22 @@ fn decode_component_control_payload(
&invalid,
)?;
- Ok(PreparedComponentControl {
- source,
- native_profile_words,
- model_node_count,
- bindings,
- timeline_rows,
- typed_records: decoded_effect.typed_records,
- node_data: None,
- effect_section: decoded_effect.section,
- initial_effect_group: decoded_effect.initial_effect_group,
- point_list: None,
- })
+ let initial_group_opcodes = decoded_effect.initial_group_opcodes;
+ Ok((
+ PreparedComponentControl {
+ source,
+ native_profile_words,
+ model_node_count,
+ bindings,
+ timeline_rows,
+ typed_records: decoded_effect.typed_records,
+ node_data: None,
+ effect_section: decoded_effect.section,
+ initial_effect_group: decoded_effect.initial_effect_group,
+ point_list: None,
+ },
+ initial_group_opcodes,
+ ))
}
fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> {
@@ -4376,7 +4438,8 @@ impl Hasher for StableHasher {
#[cfg(test)]
mod tests {
use super::*;
- use fparkan_prototype::build_prototype_graph;
+ use fparkan_binary::{sha256, sha256_hex};
+ use fparkan_prototype::{build_prototype_graph, resolve_prototype_all};
use fparkan_resource::{resource_name, CachedResourceRepository};
use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs};
use std::path::PathBuf;
@@ -4478,6 +4541,1476 @@ mod tests {
));
}
+ #[test]
+ fn research_trf_reader_preserves_rows_and_uses_low_u16_alias_link() {
+ let bytes = research_cycle_fixture(3, &[0; 5]);
+ let document = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic TRF");
+
+ assert_eq!(document.row_count(), 5);
+ assert_eq!(document.find_key(b"a").expect("key lookup"), Some(0));
+ let row = document.row_words(0).expect("first raw row");
+ assert_eq!(row[0], 10.0_f32.to_bits());
+ assert_eq!(row[1], 1.0_f32.to_bits());
+ assert_eq!(row[8], 0x09FF_0000);
+ assert_eq!(document.row_words(2).expect("third row")[8], 0x09FF_0002);
+ assert_eq!(document.flag_byte(0), Some(0));
+ assert_eq!(document.relation_2_3(0).expect("ordered links"), [3, 1]);
+ assert!(document
+ .relation_4_5(0)
+ .expect("empty optional stream")
+ .is_empty());
+ }
+
+ #[test]
+ fn research_trf_reader_rejects_the_native_trf0_attribute_guard() {
+ let bytes = research_cycle_fixture(4, &[0; 5]);
+
+ assert!(ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())).is_err());
+ }
+
+ #[test]
+ fn research_cost_matches_native_cycle_dedup_nonzero_result() {
+ let bytes = research_cycle_fixture(3, &[0; 5]);
+ let research = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic TRF");
+ let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]);
+
+ assert_eq!(
+ calc_full_research_cost(&candidate, &research).expect("cost"),
+ ResearchCost {
+ ore: 15.0,
+ power: 150.0,
+ }
+ );
+ }
+
+ #[test]
+ fn research_candidate_set_selects_later_lower_cost_and_updates_native_statuses() {
+ let costs = [(200.0, 0.0), (40.0, 0.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let empty_groups = [&[][..], &[][..]];
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x05, 0x05],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &[0, 1],
+ relations_23: &empty_groups,
+ relations_45: &empty_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let research = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic research table");
+ let first_path = parse_path("units/first.dat").expect("first candidate path");
+ let second_path = parse_path("units/second.dat").expect("second candidate path");
+ let mut vfs = MemoryVfs::default();
+ vfs.insert(
+ first_path.clone(),
+ Arc::from(research_unit_dat_payload(&[b"A"], &[0], &[1]).into_boxed_slice()),
+ );
+ vfs.insert(
+ second_path.clone(),
+ Arc::from(research_unit_dat_payload(&[b"B"], &[0], &[1]).into_boxed_slice()),
+ );
+ let mut candidates = ResearchCandidateSet {
+ entries: vec![
+ ResearchCandidate {
+ path: first_path,
+ status: 0,
+ },
+ ResearchCandidate {
+ path: second_path,
+ status: 0,
+ },
+ ],
+ selected_index: 0x1234_5678,
+ loaded: true,
+ status: 0,
+ };
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("refresh research candidates");
+
+ assert_eq!(
+ candidates.selected_index, 1,
+ "later lower-cost candidate wins"
+ );
+ assert_eq!(
+ candidates.status, 0,
+ "candidate scan status is cleared before reads"
+ );
+ assert_eq!(
+ candidates
+ .entries
+ .iter()
+ .map(|entry| entry.status)
+ .collect::<Vec<_>>(),
+ [0, 0],
+ "both eligible candidates pass predicate two with false status"
+ );
+ }
+
+ #[test]
+ fn research_candidate_set_guards_and_empty_catalog_match_native_state() {
+ let research = research_selector_document(&[0x05], &[(10.0, 0.0)], &[(b"A", 0)], &[0]);
+ let default = ResearchCandidateSet::default();
+ assert!(default.entries.is_empty());
+ assert_eq!(default.selected_index, 0);
+ assert!(!default.loaded);
+ assert_eq!(default.status, 0);
+ let mut candidates = research_candidate_set(&[("missing.dat", 7)], 41, false, 9);
+ candidates
+ .refresh_selection(&MemoryVfs::default(), Some(&research))
+ .expect("unloaded catalog is a no-op");
+ assert_eq!(candidates.selected_index, 41);
+ assert_eq!(candidates.status, 9);
+ assert_eq!(candidates.entries[0].status, 7);
+
+ candidates.loaded = true;
+ candidates
+ .refresh_selection(&MemoryVfs::default(), None)
+ .expect("missing context is a no-op");
+ assert_eq!(candidates.selected_index, 41);
+ assert_eq!(candidates.status, 9);
+ assert_eq!(candidates.entries[0].status, 7);
+
+ let mut empty = research_candidate_set(&[], 63, true, 0);
+ empty
+ .refresh_selection(&MemoryVfs::default(), Some(&research))
+ .expect("loaded empty catalog refresh");
+ assert_eq!(empty.selected_index, 63);
+ assert_eq!(empty.status, 1);
+ }
+
+ #[test]
+ fn research_candidate_set_skips_nonzero_status_without_reading_missing_path() {
+ let research = research_selector_document(&[0x05], &[(10.0, 0.0)], &[(b"A", 0)], &[0]);
+ let mut candidates = research_candidate_set(&[("missing.dat", 1)], 52, true, 0);
+
+ candidates
+ .refresh_selection(&MemoryVfs::default(), Some(&research))
+ .expect("nonzero entry status skips VFS read");
+
+ assert_eq!(candidates.selected_index, 52);
+ assert_eq!(candidates.status, 1);
+ assert_eq!(candidates.entries[0].status, 1);
+ }
+
+ #[test]
+ fn research_candidate_set_short_circuits_false_predicates_on_parsed_dat() {
+ let research = research_selector_document(&[0x03], &[(10.0, 0.0)], &[(b"A", 0)], &[0]);
+ let first_path = "units/predicate-false.dat";
+ let second_path = "units/missing-key.dat";
+ let mut vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut vfs,
+ first_path,
+ research_unit_dat_payload(&[b"A"], &[0], &[1]),
+ );
+ insert_research_dat(
+ &mut vfs,
+ second_path,
+ research_unit_dat_payload(&[b"Z"], &[0], &[1]),
+ );
+ let mut candidates =
+ research_candidate_set(&[(first_path, 0), (second_path, 0)], 0x1234_5678, true, 0);
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("false native predicates skip without cost errors");
+
+ assert_eq!(candidates.selected_index, 0x1234_5678);
+ assert_eq!(candidates.status, 0);
+ assert_eq!(
+ candidates
+ .entries
+ .iter()
+ .map(|entry| entry.status)
+ .collect::<Vec<_>>(),
+ [0, 0]
+ );
+ }
+
+ #[test]
+ fn research_candidate_set_prunes_kind_zero_subtree_before_non_ascii_lookup() {
+ let research = research_selector_document(&[0x05], &[(10.0, 0.0)], &[(b"A", 0)], &[0]);
+ let path = "units/pruned-non-ascii-grandchild.dat";
+ let non_ascii = [0xC0];
+ let keys: [&[u8]; 3] = [b"A", b"", &non_ascii];
+ let mut vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut vfs,
+ path,
+ research_unit_dat_payload(&keys, &[1, 1, 0], &[1, 0, 1]),
+ );
+ let mut candidates = research_candidate_set(&[(path, 0)], 79, true, 0);
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("kind-zero subtree is pruned before its non-ASCII key");
+
+ assert_eq!(candidates.selected_index, 0);
+ assert_eq!(candidates.status, 0);
+ assert_eq!(candidates.entries[0].status, 0);
+ }
+
+ #[test]
+ fn research_candidate_set_keeps_second_predicate_true_status_and_selection() {
+ let research = research_selector_document(&[0x07], &[(10.0, 0.0)], &[(b"A", 0)], &[0]);
+ let path = "units/second-predicate-true.dat";
+ let mut vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut vfs,
+ path,
+ research_unit_dat_payload(&[b"A"], &[0], &[1]),
+ );
+ let mut candidates = research_candidate_set(&[(path, 0)], 73, true, 0);
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("second predicate true is a candidate skip");
+
+ assert_eq!(candidates.selected_index, 73);
+ assert_eq!(candidates.status, 0);
+ assert_eq!(candidates.entries[0].status, 1);
+ }
+
+ #[test]
+ fn research_candidate_set_skips_read_and_parse_failures_then_selects_later_candidate() {
+ let research = research_selector_document(
+ &[0x05, 0x05],
+ &[(200.0, 0.0), (40.0, 0.0)],
+ &[(b"A", 0), (b"B", 1)],
+ &[0, 1],
+ );
+ let wrong_magic = "units/wrong-magic.dat";
+ let excessive_children = "units/excessive-children.dat";
+ let winner = "units/winner.dat";
+ let mut vfs = MemoryVfs::default();
+ let mut wrong_magic_bytes = research_unit_dat_payload(&[b"A"], &[0], &[1]);
+ wrong_magic_bytes[..4].copy_from_slice(&0x1234_5678_u32.to_le_bytes());
+ insert_research_dat(&mut vfs, wrong_magic, wrong_magic_bytes);
+ let mut excessive_children_bytes = research_unit_dat_payload(&[b"A"], &[0], &[1]);
+ excessive_children_bytes[8 + 108..8 + 112].copy_from_slice(&u32::MAX.to_le_bytes());
+ insert_research_dat(&mut vfs, excessive_children, excessive_children_bytes);
+ insert_research_dat(
+ &mut vfs,
+ winner,
+ research_unit_dat_payload(&[b"B"], &[0], &[1]),
+ );
+ let mut candidates = research_candidate_set(
+ &[
+ ("units/missing.dat", 0),
+ (wrong_magic, 0),
+ (excessive_children, 0),
+ (winner, 0),
+ ],
+ 91,
+ true,
+ 0,
+ );
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("read and parse failures are skipped");
+
+ assert_eq!(candidates.selected_index, 3);
+ assert_eq!(candidates.status, 0);
+ assert_eq!(
+ candidates
+ .entries
+ .iter()
+ .map(|entry| entry.status)
+ .collect::<Vec<_>>(),
+ [0, 0, 0, 0]
+ );
+ }
+
+ #[test]
+ fn research_candidate_set_uses_strict_bound_and_keeps_first_tie() {
+ let tie_research = research_selector_document(
+ &[0x05, 0x05],
+ &[(40.0, 0.0), (40.0, 0.0)],
+ &[(b"A", 0), (b"B", 1)],
+ &[0, 1],
+ );
+ let mut tie_vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut tie_vfs,
+ "units/tie-a.dat",
+ research_unit_dat_payload(&[b"A"], &[0], &[1]),
+ );
+ insert_research_dat(
+ &mut tie_vfs,
+ "units/tie-b.dat",
+ research_unit_dat_payload(&[b"B"], &[0], &[1]),
+ );
+ let mut tied = research_candidate_set(
+ &[("units/tie-a.dat", 0), ("units/tie-b.dat", 0)],
+ 99,
+ true,
+ 0,
+ );
+ tied.refresh_selection(&tie_vfs, Some(&tie_research))
+ .expect("strict comparison keeps first tied candidate");
+ assert_eq!(tied.selected_index, 0);
+
+ for ore in [f32::from_bits(0x4b18_967f), f32::NAN] {
+ let research = research_selector_document(&[0x05], &[(ore, 0.0)], &[(b"A", 0)], &[0]);
+ let mut vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut vfs,
+ "units/no-improvement.dat",
+ research_unit_dat_payload(&[b"A"], &[0], &[1]),
+ );
+ let mut candidates =
+ research_candidate_set(&[("units/no-improvement.dat", 0)], 0xABCD, true, 0);
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("equal bound and NaN cost do not select");
+ assert_eq!(candidates.selected_index, 0xABCD);
+ assert_eq!(candidates.entries[0].status, 0);
+ }
+ }
+
+ #[test]
+ fn research_candidate_set_ignores_tail_after_first_root_tree() {
+ let research = research_selector_document(
+ &[0x05, 0x05],
+ &[(200.0, 0.0), (40.0, 0.0)],
+ &[(b"A", 0), (b"B", 1)],
+ &[0, 1],
+ );
+ let trailing = "units/trailing-three-bytes.dat";
+ let orphan = "units/orphan-record.dat";
+ let mut vfs = MemoryVfs::default();
+ let mut trailing_bytes = research_unit_dat_payload(&[b"A"], &[0], &[1]);
+ trailing_bytes[4..8].copy_from_slice(&0xA5A5_5A5A_u32.to_le_bytes());
+ trailing_bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
+ let (decoded, tree) = research::decode_candidate_first_tree(&trailing_bytes)
+ .expect("first complete root with trailing bytes");
+ assert_eq!(
+ &decoded.header_opaque[4..8],
+ &0xA5A5_5A5A_u32.to_le_bytes(),
+ "second header DWORD remains opaque"
+ );
+ assert_eq!(decoded.records.len(), 1);
+ assert_eq!(tree.len(), 1);
+ insert_research_dat(&mut vfs, trailing, trailing_bytes);
+
+ let mut orphan_bytes = research_unit_dat_payload(&[b"B"], &[0], &[1]);
+ let orphan_record = research_unit_dat_payload(&[b"Z"], &[0], &[1]);
+ orphan_bytes.extend_from_slice(&orphan_record[8..]);
+ insert_research_dat(&mut vfs, orphan, orphan_bytes);
+ let mut candidates = research_candidate_set(&[(trailing, 0), (orphan, 0)], 99, true, 0);
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("suffix records are outside the first root tree");
+
+ assert_eq!(candidates.selected_index, 1);
+ assert_eq!(candidates.status, 0);
+ }
+
+ #[test]
+ fn research_candidate_set_preserves_incremental_state_on_non_ascii_error() {
+ let non_ascii = [0xC0];
+ let research = research_selector_document(
+ &[0x05, 0x05],
+ &[(10.0, 0.0), (20.0, 0.0)],
+ &[(b"A", 0), (&non_ascii, 1)],
+ &[0, 1],
+ );
+ let mut vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut vfs,
+ "units/research-a.dat",
+ research_unit_dat_payload(&[b"A"], &[0], &[1]),
+ );
+ insert_research_dat(
+ &mut vfs,
+ "units/research-non-ascii.dat",
+ research_unit_dat_payload(&[&non_ascii], &[0], &[1]),
+ );
+ let mut candidates = research_candidate_set(
+ &[
+ ("units/research-a.dat", 0),
+ ("units/research-non-ascii.dat", 0),
+ ],
+ 0xAABB_CCDD,
+ true,
+ 1,
+ );
+
+ assert!(matches!(
+ candidates.refresh_selection(&vfs, Some(&research)),
+ Err(ResearchError::UnsupportedNonAsciiLookup { .. })
+ ));
+
+ assert_eq!(candidates.selected_index, 0);
+ assert_eq!(candidates.status, 0);
+ assert_eq!(
+ candidates
+ .entries
+ .iter()
+ .map(|entry| entry.status)
+ .collect::<Vec<_>>(),
+ [0, 0]
+ );
+ }
+
+ #[test]
+ fn research_candidate_set_skips_empty_table_cost_after_pruned_tree() {
+ let research = research_selector_document(&[], &[], &[], &[]);
+ let path = "units/empty-table-kind-zero.dat";
+ let mut vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut vfs,
+ path,
+ research_unit_dat_payload(&[b""], &[0], &[0]),
+ );
+ let mut candidates = research_candidate_set(&[(path, 0)], 37, true, 0);
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("native cost failure skips this candidate");
+
+ assert_eq!(candidates.selected_index, 37);
+ assert_eq!(candidates.status, 0);
+ assert_eq!(candidates.entries[0].status, 1);
+ }
+
+ #[test]
+ fn research_trf_reader_preserves_first_alias_raw_link_and_unused_tails() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"Key".as_slice(), 0), (b"kEy".as_slice(), 1)];
+ let row_alias_links = [1, 0];
+ let empty_groups = [&[][..], &[][..]];
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x84, 0x05],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &empty_groups,
+ relations_45: &empty_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic TRF with unused payload tails");
+
+ assert_eq!(document.row_count(), 2);
+ assert_eq!(document.find_key(b"KEY").expect("alias lookup"), Some(0));
+ assert_eq!(document.find_key(b"missing").expect("missing lookup"), None);
+ assert_eq!(document.row_name(0).expect("raw low-u16 link"), b"kEy");
+ assert_eq!(document.row_words(0).expect("raw row")[8], 0x09FF_0001);
+ assert_eq!(document.flag_byte(0), Some(0x84));
+ assert_eq!(document.relation_2_3(0).expect("empty group"), &[]);
+ assert_eq!(document.relation_4_5(0).expect("empty second group"), &[]);
+
+ let trf0 = document
+ .archive()
+ .entries()
+ .iter()
+ .find(|entry| entry.meta().type_id == u32::from_le_bytes(*b"TRF0"))
+ .expect("retained TRF0 chunk");
+ assert!(document
+ .archive()
+ .payload(trf0.id())
+ .expect("retained payload")
+ .ends_with(RESEARCH_TEST_TAIL));
+ }
+
+ #[test]
+ fn research_trf_reader_rejects_required_chunk_and_attribute_errors() {
+ let cycle_costs = [
+ (1.0, 10.0),
+ (2.0, 20.0),
+ (3.0, 30.0),
+ (4.0, 40.0),
+ (5.0, 50.0),
+ ];
+ let names: [&[u8]; 5] = [b"A", b"B", b"C", b"D", b"E"];
+ let aliases: Vec<_> = names
+ .iter()
+ .enumerate()
+ .map(|(index, name)| (*name, u16::try_from(index).expect("row id")))
+ .collect();
+ let row_alias_links = [0, 1, 2, 3, 4];
+ let no_groups = [&[][..]; 5];
+
+ let invalid_attr = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 4,
+ flags: &[0; 5],
+ costs: &cycle_costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(invalid_attr.into_boxed_slice())),
+ Err(ResearchError::InvalidTrf0Attribute { got: 4 })
+ ));
+
+ let missing_required = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 5],
+ costs: &cycle_costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[u32::from_le_bytes(*b"TRF8")],
+ });
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(missing_required.into_boxed_slice())),
+ Err(ResearchError::MissingChunk { type_id })
+ if type_id == u32::from_le_bytes(*b"TRF8")
+ ));
+ }
+
+ #[test]
+ fn research_trf_reader_zero_fills_short_flags_and_rejects_long_flags() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let short = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x84],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(short.into_boxed_slice()))
+ .expect("short TRF1 is zero-filled by native loader");
+ assert_eq!(document.flag_byte(0), Some(0x84));
+ assert_eq!(document.flag_byte(1), Some(0));
+
+ let long = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 3],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(long.into_boxed_slice())),
+ Err(ResearchError::FlagsTooLong {
+ row_count: 2,
+ actual: 3
+ })
+ ));
+ }
+
+ #[test]
+ fn research_trf_reader_checks_alias_and_pair_bounds_and_non_ascii_policy() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let make_bytes = || {
+ research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ })
+ };
+
+ let mut invalid_name_offset = make_bytes();
+ research_set_payload_u16(
+ &mut invalid_name_offset,
+ u32::from_le_bytes(*b"TRFB"),
+ 0,
+ u16::MAX,
+ );
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(invalid_name_offset.into_boxed_slice())),
+ Err(ResearchError::InvalidNameOffset {
+ pair_index: 0,
+ offset: u16::MAX
+ })
+ ));
+
+ let mut unterminated = make_bytes();
+ research_set_payload_byte(&mut unterminated, u32::from_le_bytes(*b"TRF6"), 1, b'X');
+ research_set_payload_byte(&mut unterminated, u32::from_le_bytes(*b"TRF6"), 3, b'X');
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(unterminated.into_boxed_slice())),
+ Err(ResearchError::UnterminatedName {
+ pair_index: 0,
+ offset: 0
+ })
+ ));
+
+ let mut invalid_record_pair = make_bytes();
+ research_set_payload_u32(
+ &mut invalid_record_pair,
+ u32::from_le_bytes(*b"TRF0"),
+ 32,
+ 0x09FF_0002,
+ );
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(invalid_record_pair.into_boxed_slice())),
+ Err(ResearchError::InvalidRecordPair {
+ record_index: 0,
+ pair_index: 2,
+ pair_count: 2
+ })
+ ));
+
+ let mut invalid_alias_row = make_bytes();
+ research_set_payload_u16(&mut invalid_alias_row, u32::from_le_bytes(*b"TRFB"), 2, 7);
+ let document = ResearchTrfDocument::decode(Arc::from(invalid_alias_row.into_boxed_slice()))
+ .expect("alias row is checked when selected");
+ assert!(matches!(
+ document.find_key(b"A"),
+ Err(ResearchError::InvalidResearchIndex {
+ type_id,
+ index: 7,
+ row_count: 2
+ }) if type_id == u32::from_le_bytes(*b"TRFB")
+ ));
+
+ let non_ascii_aliases = [(b"\xC4".as_slice(), 0), (b"A".as_slice(), 1)];
+ let non_ascii = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &non_ascii_aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(non_ascii.into_boxed_slice()))
+ .expect("non-ASCII names are preserved");
+ assert!(matches!(
+ document.find_key(b"A"),
+ Err(ResearchError::UnsupportedNonAsciiLookup { .. })
+ ));
+ assert!(matches!(
+ document.find_key(b"\xE4"),
+ Err(ResearchError::UnsupportedNonAsciiLookup { .. })
+ ));
+ }
+
+ #[test]
+ fn research_trf_reader_checks_optional_streams_group_bounds_and_relation_ids() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let missing_zero_streams = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: false,
+ include_trf5: false,
+ omitted_chunks: &[],
+ });
+ let document =
+ ResearchTrfDocument::decode(Arc::from(missing_zero_streams.into_boxed_slice()))
+ .expect("missing optional streams with zero IDs are empty");
+ assert_eq!(
+ document.relation_2_3(0).expect("empty optional stream"),
+ &[]
+ );
+ assert_eq!(
+ document.relation_4_5(0).expect("empty optional stream"),
+ &[]
+ );
+
+ let positive_group = [&[1_u32][..], &[][..]];
+ let missing_trf3 = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &positive_group,
+ relations_45: &no_groups,
+ include_trf3: false,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(missing_trf3.into_boxed_slice())),
+ Err(ResearchError::MissingRelationStream { type_id, required_ids: 1 })
+ if type_id == u32::from_le_bytes(*b"TRF3")
+ ));
+
+ let out_of_range_23 = [&[7_u32][..], &[][..]];
+ let out_of_range_45 = [&[][..], &[9_u32][..]];
+ let invalid_ids = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &out_of_range_23,
+ relations_45: &out_of_range_45,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(invalid_ids.into_boxed_slice()))
+ .expect("indices are checked when a group is accessed");
+ assert!(matches!(
+ document.relation_2_3(0),
+ Err(ResearchError::InvalidResearchIndex { type_id, index: 7, row_count: 2 })
+ if type_id == u32::from_le_bytes(*b"TRF3")
+ ));
+ assert!(matches!(
+ document.relation_4_5(1),
+ Err(ResearchError::InvalidResearchIndex { type_id, index: 9, row_count: 2 })
+ if type_id == u32::from_le_bytes(*b"TRF5")
+ ));
+ assert!(matches!(
+ document.relation_2_3(usize::MAX),
+ Err(ResearchError::MissingRelationGroup { type_id, .. })
+ if type_id == u32::from_le_bytes(*b"TRF2")
+ ));
+
+ let one_group = [&[][..]];
+ let short_table = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &one_group,
+ relations_45: &one_group,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(short_table.into_boxed_slice()))
+ .expect("relation tables have their own counts");
+ assert!(matches!(
+ document.relation_2_3(1),
+ Err(ResearchError::MissingRelationGroup { type_id, index: 1, group_count: 1 })
+ if type_id == u32::from_le_bytes(*b"TRF2")
+ ));
+ assert!(matches!(
+ document.relation_4_5(1),
+ Err(ResearchError::MissingRelationGroup { type_id, index: 1, group_count: 1 })
+ if type_id == u32::from_le_bytes(*b"TRF4")
+ ));
+ }
+
+ #[test]
+ fn research_trf_reader_checks_count_prefixes_without_large_allocations() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let mut bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ research_set_nres_attr1(&mut bytes, u32::from_le_bytes(*b"TRF2"), 3);
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())),
+ Err(ResearchError::Binary(
+ fparkan_binary::DecodeError::UnexpectedEof { .. }
+ ))
+ ));
+ }
+
+ #[test]
+ fn research_cost_preserves_native_tree_order_and_f32_rounding() {
+ let costs = [(16_777_216.0, 20.0), (-16_777_216.0, 30.0), (1.0, 10.0)];
+ let aliases_forward = [
+ (b"A".as_slice(), 2),
+ (b"B".as_slice(), 0),
+ (b"C".as_slice(), 1),
+ ];
+ let aliases_reversed = [
+ (b"A".as_slice(), 0),
+ (b"B".as_slice(), 1),
+ (b"C".as_slice(), 2),
+ ];
+ let row_alias_links = [0, 1, 2];
+ let no_groups = [&[][..], &[][..], &[][..]];
+ let make_research = |aliases: &[(&[u8], u16)]| {
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x05; 3],
+ costs: &costs,
+ aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())).expect("f32 order TRF")
+ };
+ let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]);
+
+ let forward = calc_full_research_cost(&candidate, &make_research(&aliases_forward))
+ .expect("forward order cost");
+ assert_eq!(forward.ore.to_bits(), 0.0_f32.to_bits());
+ assert_eq!(forward.power.to_bits(), 60.0_f32.to_bits());
+
+ let reversed = calc_full_research_cost(&candidate, &make_research(&aliases_reversed))
+ .expect("reversed order cost");
+ assert_eq!(reversed.ore.to_bits(), 1.0_f32.to_bits());
+ assert_eq!(reversed.power.to_bits(), 60.0_f32.to_bits());
+ }
+
+ #[test]
+ fn research_cost_obeys_subtree_and_record_gates() {
+ let costs = [
+ (1.0, 10.0),
+ (2.0, 20.0),
+ (3.0, 30.0),
+ (4.0, 40.0),
+ (5.0, 50.0),
+ ];
+ let names: [&[u8]; 5] = [b"A", b"B", b"C", b"D", b"E"];
+ let aliases: Vec<_> = names
+ .iter()
+ .enumerate()
+ .map(|(index, name)| (*name, u16::try_from(index).expect("row id")))
+ .collect();
+ let row_alias_links = [0, 1, 2, 3, 4];
+ let relations_45 = [&[][..]; 5];
+ let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]);
+
+ let recursion_gate = research_cycle_fixture(3, &[0x05, 0x05, 0x05, 0x04, 0x04]);
+ let research = ResearchTrfDocument::decode(Arc::from(recursion_gate.into_boxed_slice()))
+ .expect("recursion-gate TRF");
+ assert_eq!(
+ calc_full_research_cost(&candidate, &research).expect("cost"),
+ ResearchCost {
+ ore: 6.0,
+ power: 60.0
+ }
+ );
+
+ let include_gate = research_cycle_fixture(3, &[0x04, 0x05, 0x05, 0x06, 0x04]);
+ let research = ResearchTrfDocument::decode(Arc::from(include_gate.into_boxed_slice()))
+ .expect("include-gate TRF");
+ assert_eq!(
+ calc_full_research_cost(&candidate, &research).expect("cost"),
+ ResearchCost {
+ ore: 6.0,
+ power: 60.0
+ }
+ );
+
+ let no_groups = [&[][..]; 5];
+ let isolated = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x04; 5],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &relations_45,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let research = ResearchTrfDocument::decode(Arc::from(isolated.into_boxed_slice()))
+ .expect("isolated TRF");
+ let child_pruned = research_unit_dat(&[b"A", b"B", b"C"], &[1, 1, 0], &[1, 0, 1]);
+ assert_eq!(
+ calc_full_research_cost(&child_pruned, &research).expect("pruned child cost"),
+ ResearchCost {
+ ore: 1.0,
+ power: 10.0
+ }
+ );
+ let root_pruned = research_unit_dat(&[b"A", b"B"], &[1, 0], &[0, 1]);
+ assert_eq!(
+ calc_full_research_cost(&root_pruned, &research).expect("pruned root cost"),
+ ResearchCost {
+ ore: 0.0,
+ power: 0.0
+ }
+ );
+ }
+
+ #[test]
+ fn research_cost_rejects_unknown_keys_unterminated_keys_and_missing_groups() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let make_document = |groups: &[&[u32]]| {
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x04; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("research table")
+ };
+
+ let no_group_document = make_document(&[]);
+ let candidate = research_unit_dat(&[b"A"], &[0], &[1]);
+ assert!(matches!(
+ calc_full_research_cost(&candidate, &no_group_document),
+ Err(ResearchError::MissingRelationGroup {
+ type_id,
+ index: 0,
+ group_count: 0
+ }) if type_id == u32::from_le_bytes(*b"TRF2")
+ ));
+
+ let valid_groups = [&[][..], &[][..]];
+ let document = make_document(&valid_groups);
+ let unknown = research_unit_dat(&[b"Z"], &[0], &[1]);
+ assert!(matches!(
+ calc_full_research_cost(&unknown, &document),
+ Err(ResearchError::UnknownResearchKey {
+ component_index: 0,
+ ..
+ })
+ ));
+ let unterminated = research_unit_dat(&[&[b'A'; 32]], &[0], &[1]);
+ assert!(matches!(
+ calc_full_research_cost(&unterminated, &document),
+ Err(ResearchError::MissingCandidateKeyTerminator { component_index: 0 })
+ ));
+ }
+
+ #[test]
+ #[ignore = "requires licensed Part 1 corpus"]
+ fn licensed_scream_trf_and_12hel1_dat_exercise_research_cost() {
+ let root = fixture_root("IS");
+ let trf =
+ std::fs::read(root.join("MISSIONS/SCRIPTS/scream.trf")).expect("licensed scream.trf");
+ let research = ResearchTrfDocument::decode(Arc::from(trf.into_boxed_slice()))
+ .expect("decode licensed research table");
+ assert_eq!(research.row_count(), 368);
+ assert_eq!(research.find_key(b"R_T_02").expect("R_T_02"), Some(205));
+ assert_eq!(research.row_name(205).expect("row 205 name"), b"R_T_02");
+ assert_eq!(
+ &research.row_words(205).expect("row 205 words")[..4],
+ &[0, 0, 0x40A0_0000, 0x4140_0000]
+ );
+ assert_eq!(research.flag_byte(205), Some(0x07));
+ assert!(research
+ .relation_2_3(205)
+ .expect("row 205 TRF2/TRF3 group")
+ .is_empty());
+ assert_eq!(
+ research.relation_4_5(205).expect("row 205 second group"),
+ &[196]
+ );
+
+ let bytes =
+ std::fs::read(root.join("UNITS/UNITS/AI/12hel1.dat")).expect("licensed 12hel1.dat");
+ let candidate = fparkan_prototype::decode_unit_dat(&bytes).expect("decode Unit DAT");
+ calc_full_research_cost(&candidate, &research).expect("calculate candidate research cost");
+ }
+
+ #[test]
+ #[ignore = "requires licensed Part 1 corpus"]
+ fn licensed_part1_ai_candidate_prefixes_and_ordered_research_scan() {
+ let root = fixture_root("IS");
+ let vfs = DirectoryVfs::new(&root);
+ let table_path = parse_path("MISSIONS/SCRIPTS/scream.trf").expect("research path");
+ let table_bytes = vfs.read(&table_path).expect("licensed scream.trf");
+ let research = ResearchTrfDocument::decode(table_bytes).expect("decode scream.trf");
+ let prefix = parse_path("UNITS/UNITS/AI").expect("AI unit directory");
+ let mut candidate_paths = vfs
+ .list(&prefix)
+ .expect("list Part 1 AI assets")
+ .into_iter()
+ .filter_map(|entry| {
+ entry
+ .path
+ .as_bytes()
+ .to_ascii_lowercase()
+ .ends_with(b".dat")
+ .then_some(entry.path)
+ })
+ .collect::<Vec<_>>();
+ candidate_paths.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
+ assert!(
+ !candidate_paths.is_empty(),
+ "Part 1 AI catalog has DAT files"
+ );
+ assert!(candidate_paths
+ .iter()
+ .all(|path| path.as_bytes().is_ascii()));
+ assert!(candidate_paths
+ .windows(2)
+ .all(|pair| pair[0].as_bytes() <= pair[1].as_bytes()));
+
+ for path in &candidate_paths {
+ let bytes = vfs
+ .read(path)
+ .unwrap_or_else(|error| panic!("{}: {error}", path.as_str()));
+ research::decode_candidate_first_tree(&bytes)
+ .unwrap_or_else(|error| panic!("{}: {error}", path.as_str()));
+ }
+
+ let mut candidates = ResearchCandidateSet {
+ entries: candidate_paths
+ .iter()
+ .cloned()
+ .map(|path| ResearchCandidate { path, status: 0 })
+ .collect(),
+ selected_index: 0,
+ loaded: true,
+ status: 0xA5,
+ };
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("scan Part 1 AI candidates in byte-sorted supplied order");
+ assert!(
+ usize::try_from(candidates.selected_index)
+ .is_ok_and(|index| index < candidate_paths.len()),
+ "selected index remains within the supplied catalog"
+ );
+ assert_eq!(
+ candidates.status, 0,
+ "at least one catalog entry was scanned"
+ );
+ }
+
+ #[test]
+ #[ignore = "requires licensed Part 1 corpus"]
+ fn licensed_part1_research_trf_files_all_decode() {
+ fn collect_trf_files(directory: &std::path::Path, files: &mut Vec<PathBuf>) {
+ for entry in std::fs::read_dir(directory).expect("read licensed directory") {
+ let entry = entry.expect("read directory entry");
+ let path = entry.path();
+ let kind = entry.file_type().expect("read entry type");
+ if kind.is_dir() {
+ collect_trf_files(&path, files);
+ } else if !kind.is_symlink()
+ && path
+ .extension()
+ .map(|extension| extension.to_string_lossy().eq_ignore_ascii_case("trf"))
+ .unwrap_or(false)
+ {
+ files.push(path);
+ }
+ }
+ }
+
+ let root = fixture_root("IS");
+ let mut files = Vec::new();
+ collect_trf_files(&root, &mut files);
+ files.sort();
+ assert!(!files.is_empty(), "licensed corpus contains no .trf files");
+ for path in files {
+ let bytes = std::fs::read(&path).expect("read TRF resource");
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
+ }
+ }
+
+ const RESEARCH_TEST_TAIL: &[u8] = &[0xA5, 0x5A];
+
+ struct ResearchFixtureSpec<'a> {
+ trf0_attr2: u32,
+ flags: &'a [u8],
+ costs: &'a [(f32, f32)],
+ aliases: &'a [(&'a [u8], u16)],
+ row_alias_links: &'a [u16],
+ relations_23: &'a [&'a [u32]],
+ relations_45: &'a [&'a [u32]],
+ include_trf3: bool,
+ include_trf5: bool,
+ omitted_chunks: &'a [u32],
+ }
+
+ fn research_selector_document(
+ flags: &[u8],
+ costs: &[(f32, f32)],
+ aliases: &[(&[u8], u16)],
+ row_alias_links: &[u16],
+ ) -> ResearchTrfDocument {
+ let empty_groups = vec![&[][..]; costs.len()];
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags,
+ costs,
+ aliases,
+ row_alias_links,
+ relations_23: &empty_groups,
+ relations_45: &empty_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic research selector table")
+ }
+
+ fn research_candidate_set(
+ entries: &[(&str, u8)],
+ selected_index: u32,
+ loaded: bool,
+ status: u8,
+ ) -> ResearchCandidateSet {
+ ResearchCandidateSet {
+ entries: entries
+ .iter()
+ .map(|(path, status)| ResearchCandidate {
+ path: parse_path(path).expect("candidate path"),
+ status: *status,
+ })
+ .collect(),
+ selected_index,
+ loaded,
+ status,
+ }
+ }
+
+ fn insert_research_dat(vfs: &mut MemoryVfs, path: &str, bytes: Vec<u8>) {
+ vfs.insert(
+ parse_path(path).expect("candidate path"),
+ Arc::from(bytes.into_boxed_slice()),
+ );
+ }
+
+ fn research_fixture(spec: ResearchFixtureSpec<'_>) -> Vec<u8> {
+ assert_eq!(spec.costs.len(), spec.row_alias_links.len());
+
+ let mut rows = Vec::with_capacity(spec.costs.len());
+ for ((ore, power), pair_index) in spec.costs.iter().zip(spec.row_alias_links) {
+ let mut row = [0_u32; 10];
+ row[0] = power.to_bits();
+ row[1] = ore.to_bits();
+ row[8] = 0x09FF_0000 | u32::from(*pair_index);
+ rows.push(row);
+ }
+ let rows_payload = research_words_payload(
+ &rows
+ .iter()
+ .flat_map(|row| row.iter().copied())
+ .collect::<Vec<_>>(),
+ );
+
+ let mut name_pool = Vec::new();
+ let mut pairs = Vec::with_capacity(spec.aliases.len());
+ for (name, row_index) in spec.aliases {
+ let name_offset = u16::try_from(name_pool.len()).expect("test pool offset");
+ name_pool.extend_from_slice(name);
+ name_pool.push(0);
+ pairs.push((name_offset, *row_index));
+ }
+ let pairs_payload = research_pairs_payload(&pairs);
+ let (counts_23, ids_23) = research_relation_payload(spec.relations_23);
+ let (counts_45, ids_45) = research_relation_payload(spec.relations_45);
+ let with_tail = |mut payload: Vec<u8>| {
+ payload.extend_from_slice(RESEARCH_TEST_TAIL);
+ payload
+ };
+
+ let mut payloads = vec![
+ (
+ u32::from_le_bytes(*b"TRF0"),
+ with_tail(rows_payload),
+ spec.trf0_attr2,
+ ),
+ (u32::from_le_bytes(*b"TRF1"), spec.flags.to_vec(), 0),
+ (u32::from_le_bytes(*b"TRFB"), with_tail(pairs_payload), 0),
+ (u32::from_le_bytes(*b"TRF6"), name_pool, 0),
+ (u32::from_le_bytes(*b"TRF7"), Vec::new(), 0),
+ (u32::from_le_bytes(*b"TRF8"), Vec::new(), 0),
+ (u32::from_le_bytes(*b"TRF9"), Vec::new(), 0),
+ (u32::from_le_bytes(*b"TRFA"), Vec::new(), 0),
+ (u32::from_le_bytes(*b"TRF2"), with_tail(counts_23), 0),
+ (u32::from_le_bytes(*b"TRF4"), with_tail(counts_45), 0),
+ ];
+ if spec.include_trf3 {
+ payloads.push((u32::from_le_bytes(*b"TRF3"), with_tail(ids_23), 0));
+ }
+ if spec.include_trf5 {
+ payloads.push((u32::from_le_bytes(*b"TRF5"), with_tail(ids_45), 0));
+ }
+
+ let entries: Vec<_> = payloads
+ .iter()
+ .filter(|(type_id, _, _)| !spec.omitted_chunks.contains(type_id))
+ .map(|(type_id, payload, attr2)| TestNresEntry {
+ name: b"ResTree",
+ payload,
+ type_id: *type_id,
+ attr2: *attr2,
+ })
+ .collect();
+ let mut archive = build_nres_with_meta(&entries);
+ for (type_id, count) in [
+ (u32::from_le_bytes(*b"TRF0"), spec.costs.len()),
+ (u32::from_le_bytes(*b"TRF1"), spec.flags.len()),
+ (u32::from_le_bytes(*b"TRFB"), spec.aliases.len()),
+ (u32::from_le_bytes(*b"TRF2"), spec.relations_23.len()),
+ (u32::from_le_bytes(*b"TRF4"), spec.relations_45.len()),
+ ] {
+ if !spec.omitted_chunks.contains(&type_id) {
+ research_set_nres_attr1(
+ &mut archive,
+ type_id,
+ u32::try_from(count).expect("test count"),
+ );
+ }
+ }
+ if spec.include_trf3 && !spec.omitted_chunks.contains(&u32::from_le_bytes(*b"TRF3")) {
+ let count: usize = spec.relations_23.iter().map(|group| group.len()).sum();
+ research_set_nres_attr1(
+ &mut archive,
+ u32::from_le_bytes(*b"TRF3"),
+ u32::try_from(count).expect("test dependency count"),
+ );
+ }
+ if spec.include_trf5 && !spec.omitted_chunks.contains(&u32::from_le_bytes(*b"TRF5")) {
+ let count: usize = spec.relations_45.iter().map(|group| group.len()).sum();
+ research_set_nres_attr1(
+ &mut archive,
+ u32::from_le_bytes(*b"TRF5"),
+ u32::try_from(count).expect("test dependency count"),
+ );
+ }
+ archive
+ }
+
+ fn research_relation_payload(groups: &[&[u32]]) -> (Vec<u8>, Vec<u8>) {
+ let counts: Vec<_> = groups
+ .iter()
+ .map(|group| u32::try_from(group.len()).expect("test dependency group count"))
+ .collect();
+ let ids: Vec<_> = groups
+ .iter()
+ .flat_map(|group| group.iter().copied())
+ .collect();
+ (
+ research_words_payload(&counts),
+ research_words_payload(&ids),
+ )
+ }
+
+ fn research_set_payload_bytes(bytes: &mut [u8], type_id: u32, offset: usize, value: &[u8]) {
+ let entry_count = u32::from_le_bytes(bytes[8..12].try_into().expect("entry count"));
+ let total_size = u32::from_le_bytes(bytes[12..16].try_into().expect("total size"));
+ let directory_start = usize::try_from(total_size - entry_count * 64).expect("directory");
+ for entry in bytes[directory_start..].chunks_exact(64) {
+ if u32::from_le_bytes(entry[..4].try_into().expect("type id")) != type_id {
+ continue;
+ }
+ let payload_start = usize::try_from(u32::from_le_bytes(
+ entry[56..60].try_into().expect("payload offset"),
+ ))
+ .expect("payload offset");
+ let payload_len = usize::try_from(u32::from_le_bytes(
+ entry[12..16].try_into().expect("payload size"),
+ ))
+ .expect("payload size");
+ let end = offset.checked_add(value.len()).expect("payload end");
+ assert!(end <= payload_len, "test mutation stays within payload");
+ bytes[payload_start + offset..payload_start + end].copy_from_slice(value);
+ return;
+ }
+ panic!("NRes entry type {type_id:#010x} was not built");
+ }
+
+ fn research_set_payload_byte(bytes: &mut [u8], type_id: u32, offset: usize, value: u8) {
+ research_set_payload_bytes(bytes, type_id, offset, &[value]);
+ }
+
+ fn research_set_payload_u16(bytes: &mut [u8], type_id: u32, offset: usize, value: u16) {
+ research_set_payload_bytes(bytes, type_id, offset, &value.to_le_bytes());
+ }
+
+ fn research_set_payload_u32(bytes: &mut [u8], type_id: u32, offset: usize, value: u32) {
+ research_set_payload_bytes(bytes, type_id, offset, &value.to_le_bytes());
+ }
+
+ fn research_cycle_fixture(trf0_attr2: u32, flags: &[u8]) -> Vec<u8> {
+ let costs = [
+ (1.0_f32, 10.0_f32),
+ (2.0_f32, 20.0_f32),
+ (3.0_f32, 30.0_f32),
+ (4.0_f32, 40.0_f32),
+ (5.0_f32, 50.0_f32),
+ ];
+ let mut pool = Vec::new();
+ let mut pairs = Vec::new();
+ let mut rows = Vec::new();
+ for (index, ((ore, power), name)) in costs
+ .iter()
+ .zip([b"A".as_slice(), b"B", b"C", b"D", b"E"])
+ .enumerate()
+ {
+ let offset = u16::try_from(pool.len()).expect("pool offset");
+ pool.extend_from_slice(name);
+ pool.push(0);
+ pairs.push((offset, u16::try_from(index).expect("row index")));
+ rows.push([
+ power.to_bits(),
+ ore.to_bits(),
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0x09FF_0000 | u32::try_from(index).expect("pair index"),
+ 0,
+ ]);
+ }
+
+ let rows_payload = research_words_payload(
+ &rows
+ .iter()
+ .flat_map(|row| row.iter().copied())
+ .collect::<Vec<_>>(),
+ );
+ let pairs_payload = research_pairs_payload(&pairs);
+ let count_23 = [2_u32, 0, 0, 1, 1];
+ let ids_23 = [3_u32, 1, 4, 0];
+ let count_45 = [0_u32; 5];
+ let count_23_payload = research_words_payload(&count_23);
+ let ids_23_payload = research_words_payload(&ids_23);
+ let count_45_payload = research_words_payload(&count_45);
+ let payloads: [(u32, &[u8], u32); 11] = [
+ (
+ u32::from_le_bytes(*b"TRF0"),
+ rows_payload.as_slice(),
+ trf0_attr2,
+ ),
+ (u32::from_le_bytes(*b"TRF1"), flags, 0),
+ (u32::from_le_bytes(*b"TRFB"), pairs_payload.as_slice(), 0),
+ (u32::from_le_bytes(*b"TRF6"), pool.as_slice(), 0),
+ (u32::from_le_bytes(*b"TRF7"), &[][..], 0),
+ (u32::from_le_bytes(*b"TRF8"), &[][..], 0),
+ (u32::from_le_bytes(*b"TRF9"), &[][..], 0),
+ (u32::from_le_bytes(*b"TRFA"), &[][..], 0),
+ (u32::from_le_bytes(*b"TRF2"), &count_23_payload, 0),
+ (u32::from_le_bytes(*b"TRF3"), &ids_23_payload, 0),
+ (u32::from_le_bytes(*b"TRF4"), &count_45_payload, 0),
+ ];
+ let entries: Vec<_> = payloads
+ .iter()
+ .map(|(type_id, payload, attr2)| TestNresEntry {
+ name: b"ResTree",
+ payload,
+ type_id: *type_id,
+ attr2: *attr2,
+ })
+ .collect();
+ let mut archive = build_nres_with_meta(&entries);
+ for (type_id, count) in [
+ (u32::from_le_bytes(*b"TRF0"), 5),
+ (u32::from_le_bytes(*b"TRF1"), 5),
+ (u32::from_le_bytes(*b"TRFB"), 5),
+ (u32::from_le_bytes(*b"TRF2"), 5),
+ (u32::from_le_bytes(*b"TRF3"), 3),
+ (u32::from_le_bytes(*b"TRF4"), 5),
+ ] {
+ research_set_nres_attr1(&mut archive, type_id, count);
+ }
+ archive
+ }
+
+ fn research_words_payload(words: &[u32]) -> Vec<u8> {
+ words.iter().flat_map(|word| word.to_le_bytes()).collect()
+ }
+
+ fn research_pairs_payload(pairs: &[(u16, u16)]) -> Vec<u8> {
+ pairs
+ .iter()
+ .flat_map(|(offset, index)| offset.to_le_bytes().into_iter().chain(index.to_le_bytes()))
+ .collect()
+ }
+
+ fn research_set_nres_attr1(bytes: &mut [u8], type_id: u32, count: u32) {
+ let entry_count = u32::from_le_bytes(bytes[8..12].try_into().expect("entry count"));
+ let total_size = u32::from_le_bytes(bytes[12..16].try_into().expect("total size"));
+ let directory_start = usize::try_from(total_size - entry_count * 64).expect("directory");
+ for entry in bytes[directory_start..].chunks_exact_mut(64) {
+ if u32::from_le_bytes(entry[..4].try_into().expect("type id")) == type_id {
+ entry[4..8].copy_from_slice(&count.to_le_bytes());
+ return;
+ }
+ }
+ panic!("NRes entry type {type_id:#010x} was not built");
+ }
+
+ fn research_unit_dat_payload(keys: &[&[u8]], child_counts: &[u32], kinds: &[u32]) -> Vec<u8> {
+ assert_eq!(keys.len(), child_counts.len());
+ assert_eq!(keys.len(), kinds.len());
+ let mut bytes = vec![0; 8 + keys.len() * 112];
+ bytes[..4].copy_from_slice(&0x0000_F0F1_u32.to_le_bytes());
+ for (index, key) in keys.iter().enumerate() {
+ let offset = 8 + index * 112;
+ bytes[offset + 32..offset + 32 + key.len()].copy_from_slice(key);
+ bytes[offset + 64..offset + 68].copy_from_slice(&kinds[index].to_le_bytes());
+ bytes[offset + 108..offset + 112].copy_from_slice(&child_counts[index].to_le_bytes());
+ }
+ bytes
+ }
+
+ fn research_unit_dat(
+ keys: &[&[u8]],
+ child_counts: &[u32],
+ kinds: &[u32],
+ ) -> fparkan_prototype::UnitDat {
+ fparkan_prototype::decode_unit_dat(&research_unit_dat_payload(keys, child_counts, kinds))
+ .expect("candidate Unit DAT")
+ }
+
fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord {
let mut fixed_words = [0; 44];
fixed_words[1] = key;
@@ -5063,6 +6596,132 @@ mod tests {
}
#[test]
+ fn component_control_tag17_shares_common_cursor_and_preserves_raw_record_tails() {
+ let source = ResourceKey {
+ archive: parse_path("controls.rlb").expect("archive"),
+ name: resource_name(b"parts.ctl"),
+ type_id: None,
+ };
+ let mut payload = component_control_payload(&[(1, 0.0, 3.0, 0.5, 0)]);
+ payload[12..16].copy_from_slice(&2_u32.to_le_bytes());
+ payload[16..20].copy_from_slice(&1_u32.to_le_bytes());
+
+ let tag17_start = payload.len();
+ append_typed_record(&mut payload, 17, 2);
+ payload[tag17_start + 0x14..tag17_start + 0x18]
+ .copy_from_slice(&0xAABB_CCDDu32.to_le_bytes());
+ payload[tag17_start + 0xAC..tag17_start + 0xB0].copy_from_slice(&2_u32.to_le_bytes());
+ payload.splice(
+ tag17_start + 0xB0..tag17_start + 0xB0,
+ [0x1122_3344u32.to_le_bytes(), 0x5566_7788u32.to_le_bytes()].concat(),
+ );
+ payload.extend_from_slice(b"fx\0");
+
+ let known_start = payload.len();
+ append_typed_record(&mut payload, 4, 3);
+ payload[known_start + 0x28..known_start + 0x2C]
+ .copy_from_slice(&0xCAFE_D00Du32.to_le_bytes());
+ payload[known_start + 0xAC..known_start + 0xB0].copy_from_slice(&1_u32.to_le_bytes());
+ payload.splice(
+ known_start + 0xB0..known_start + 0xB0,
+ 0x0102_0304u32.to_le_bytes(),
+ );
+ payload.extend_from_slice(b"ctl\0");
+
+ let group_header = payload.len();
+ payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
+ append_effect_table(
+ &mut payload,
+ &[effect_row(
+ 4,
+ [4, 5, 6],
+ [12, 13, 14],
+ 15,
+ "effects.rlb",
+ "first",
+ )],
+ );
+
+ let control = decode_component_control_payload(&payload, source, 2, 0)
+ .expect("tag 17 and the following known record share the typed cursor");
+
+ assert_eq!(
+ control
+ .typed_records
+ .iter()
+ .map(|record| record.tag)
+ .collect::<Vec<_>>(),
+ [17, 4]
+ );
+ assert_eq!(control.typed_records[0].fixed_words[5], 0xAABB_CCDD);
+ assert_eq!(control.typed_records[0].values, [0x1122_3344, 0x5566_7788]);
+ assert_eq!(control.typed_records[0].string_bytes, b"fx");
+ assert_eq!(
+ control.typed_records[0].gate_provider,
+ PreparedControlGateProvider::NotApplicable
+ );
+ assert_eq!(control.typed_records[1].fixed_words[10], 0xCAFE_D00D);
+ assert_eq!(control.typed_records[1].values, [0x0102_0304]);
+ assert_eq!(control.typed_records[1].string_bytes, b"ctl");
+ assert_eq!(control.initial_effect_group, Some(0));
+ assert_eq!(
+ control.effect_section,
+ PreparedControlEffectSection::Parsed(vec![PreparedControlEffectRow {
+ table_index: 0,
+ row_index: 0,
+ raw_condition: [4, 5, 6],
+ point_offsets: [12, 13, 14],
+ selector: 15,
+ resource: PreparedControlResourcePair {
+ archive: fixed_name("effects.rlb"),
+ name: fixed_name("first"),
+ },
+ }])
+ );
+ }
+
+ #[test]
+ fn component_control_truncated_tag17_frames_are_rejected() {
+ let source = ResourceKey {
+ archive: parse_path("controls.rlb").expect("archive"),
+ name: resource_name(b"parts.ctl"),
+ type_id: None,
+ };
+ let mut valid = component_control_payload(&[]);
+ valid[12..16].copy_from_slice(&1_u32.to_le_bytes());
+ let record_start = valid.len();
+ append_typed_record(&mut valid, 17, 2);
+ valid[record_start + 0xAC..record_start + 0xB0].copy_from_slice(&2_u32.to_le_bytes());
+ valid.splice(
+ record_start + 0xB0..record_start + 0xB0,
+ [0x1234_5678u32.to_le_bytes(), 0x89AB_CDEFu32.to_le_bytes()].concat(),
+ );
+ valid.extend_from_slice(b"xy\0");
+
+ let assert_invalid = |payload: Vec<u8>, label: &str| {
+ assert!(
+ matches!(
+ decode_component_control_payload(&payload, source.clone(), 0, 0),
+ Err(AssetError::InvalidPrototype(_))
+ ),
+ "truncated recognized tag 17 {label} must be InvalidPrototype"
+ );
+ };
+
+ for (end, label) in [
+ (record_start + 0xAF, "prefix"),
+ (record_start + 0xB0 + 4, "values"),
+ (record_start + 0xB0 + 8, "length word"),
+ (record_start + 0xB0 + 8 + 4 + 2, "string bytes"),
+ ] {
+ assert_invalid(valid[..end].to_vec(), label);
+ }
+ let mut bad_nul = valid;
+ *bad_nul.last_mut().expect("string has trailing NUL") = b'!';
+ assert_invalid(bad_nul, "trailing NUL");
+ }
+
+ #[test]
fn component_control_malformed_recognized_typed_tail_is_rejected() {
let source = ResourceKey {
archive: parse_path("controls.rlb").expect("archive"),
@@ -5206,6 +6865,138 @@ mod tests {
}
#[test]
+ fn initial_control_effect_opcodes_keep_raw_selected_rows_in_order() {
+ let source = ResourceKey {
+ archive: parse_path("controls.rlb").expect("archive"),
+ name: resource_name(b"parts.ctl"),
+ type_id: None,
+ };
+ let mut payload = component_control_payload(&[]);
+ payload[16..20].copy_from_slice(&1_u32.to_le_bytes());
+ let group_header = payload.len();
+ payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
+ let opcodes = [1, 4, 4, 12];
+ append_effect_table(
+ &mut payload,
+ &opcodes.map(|opcode| effect_row(opcode, [0; 3], [0; 3], 0, "", "")),
+ );
+
+ let (control, decoded_opcodes) =
+ decode_component_control_payload_with_opcodes(&payload, source.clone(), 2, 0)
+ .expect("complete selected group");
+ assert_eq!(decoded_opcodes, Some(opcodes.to_vec()));
+ let PreparedControlEffectSection::Parsed(rows) = control.effect_section else {
+ panic!("opcode-4 projection should remain parsed");
+ };
+ assert_eq!(
+ rows.iter().map(|row| row.row_index).collect::<Vec<_>>(),
+ [1, 2]
+ );
+
+ let repository = repository_with_archives(&[("controls.rlb", &[(b"parts.ctl", &payload)])]);
+ let manager = AssetManager::new(repository);
+ assert_eq!(
+ manager
+ .initial_control_effect_opcodes(&source, 2, 0)
+ .expect("public raw-opcode scanner"),
+ Some(opcodes.to_vec())
+ );
+ }
+
+ #[test]
+ fn initial_control_effect_opcodes_distinguish_complete_empty_from_unknown_shapes() {
+ let source = |name: &[u8]| ResourceKey {
+ archive: parse_path("controls.rlb").expect("archive"),
+ name: resource_name(name),
+ type_id: None,
+ };
+ let empty = component_control_payload(&[]);
+
+ let mut selected_noop = component_control_payload(&[]);
+ selected_noop[16..20].copy_from_slice(&1_u32.to_le_bytes());
+ let noop_header = selected_noop.len();
+ selected_noop.resize(noop_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
+ append_effect_table(&mut selected_noop, &[]);
+
+ let mut unsupported = component_control_payload(&[]);
+ unsupported[12..16].copy_from_slice(&1_u32.to_le_bytes());
+ append_typed_record(&mut unsupported, 99, 0);
+
+ let mut trailing_data = component_control_payload(&[]);
+ trailing_data.push(0xAA);
+ let repository = repository_with_archives(&[(
+ "controls.rlb",
+ &[
+ (b"empty.ctl", &empty),
+ (b"noop.ctl", &selected_noop),
+ (b"unsupported.ctl", &unsupported),
+ (b"trailing.ctl", &trailing_data),
+ ],
+ )]);
+ let manager = AssetManager::new(repository);
+
+ assert_eq!(
+ manager
+ .initial_control_effect_opcodes(&source(b"empty.ctl"), 2, 0)
+ .expect("complete empty CTLD"),
+ Some(Vec::new())
+ );
+ assert_eq!(
+ manager
+ .initial_control_effect_opcodes(&source(b"noop.ctl"), 2, 0)
+ .expect("complete no-op selected group"),
+ Some(Vec::new())
+ );
+ for name in [b"unsupported.ctl".as_slice(), b"trailing.ctl".as_slice()] {
+ assert_eq!(
+ manager
+ .initial_control_effect_opcodes(&source(name), 2, 0)
+ .expect("unsupported but bounded shape"),
+ None,
+ "{} remains unavailable",
+ String::from_utf8_lossy(name)
+ );
+ }
+ }
+
+ #[test]
+ fn initial_control_effect_opcodes_treat_out_of_range_initial_indices_as_known_empty() {
+ let make_payload = |raw_initial_group: u32| {
+ let mut payload = component_control_payload(&[]);
+ payload[16..20].copy_from_slice(&1_u32.to_le_bytes());
+ let group_header = payload.len();
+ payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
+ payload[group_header..group_header + 4]
+ .copy_from_slice(&raw_initial_group.to_le_bytes());
+ append_effect_table(&mut payload, &[effect_row(1, [0; 3], [0; 3], 0, "", "")]);
+ payload
+ };
+ let sentinel = make_payload(u32::MAX);
+ let exact_count = make_payload(1);
+ let repository = repository_with_archives(&[(
+ "controls.rlb",
+ &[
+ (b"sentinel.ctl", &sentinel),
+ (b"exact-count.ctl", &exact_count),
+ ],
+ )]);
+ let manager = AssetManager::new(repository);
+ let sources =
+ [b"sentinel.ctl".as_slice(), b"exact-count.ctl".as_slice()].map(|name| ResourceKey {
+ archive: parse_path("controls.rlb").expect("archive"),
+ name: resource_name(name),
+ type_id: None,
+ });
+ let results = sources.map(|source| {
+ manager
+ .initial_control_effect_opcodes(&source, 2, 0)
+ .expect("complete CTLD frames")
+ });
+
+ assert_eq!(results, [Some(Vec::new()), Some(Vec::new())]);
+ }
+
+ #[test]
fn tag21_gate_provider_comes_from_validated_explicit_ndp() {
let source = ResourceKey {
archive: parse_path("parts.rlb").expect("CTL archive"),
@@ -6678,6 +8469,90 @@ mod tests {
}
#[test]
+ fn mission_asset_plan_counts_texture_and_lightmap_requests() {
+ let msh = minimal_model_archive();
+ let mat0 = mat0_with_texture(b"TEX_A");
+ let texm = texm_payload();
+ let repo = repository_with_archives_meta(&[
+ (
+ "static.rlb",
+ &[
+ TestNresEntry {
+ name: b"tree.msh",
+ payload: &msh,
+ type_id: 0x4853_454D,
+ attr2: 0,
+ },
+ TestNresEntry {
+ name: b"tree.wea",
+ payload: b"2\n0 MAT_A\n1 MAT_A\n\nLIGHTMAPS\n2\n0 LM_A\n1 LM_A\n",
+ type_id: WEAR_KIND,
+ attr2: 0,
+ },
+ ],
+ ),
+ (
+ "material.lib",
+ &[TestNresEntry {
+ name: b"MAT_A",
+ payload: &mat0,
+ type_id: MAT0_KIND,
+ attr2: 0,
+ }],
+ ),
+ (
+ TEXTURES_ARCHIVE,
+ &[TestNresEntry {
+ name: b"TEX_A",
+ payload: &texm,
+ type_id: 0,
+ attr2: 0,
+ }],
+ ),
+ (
+ LIGHTMAP_ARCHIVE,
+ &[TestNresEntry {
+ name: b"LM_A",
+ payload: &texm,
+ type_id: 0,
+ attr2: 0,
+ }],
+ ),
+ ]);
+ let mesh_key = ResourceKey {
+ archive: parse_path("static.rlb").expect("archive"),
+ name: resource_name(b"tree.msh"),
+ type_id: Some(0x4853_454D),
+ };
+ let prototype = EffectivePrototype {
+ key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
+ geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
+ source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
+ dependencies: vec![mesh_key],
+ control_resource: None,
+ point_resource: None,
+ node_data_resource: None,
+ bas_resource: None,
+ };
+ let (assets, report) = prepare_mission_assets_profiled_with_repository(
+ &repo,
+ &[0..1],
+ &[prototype],
+ AssetPreparationLimits::default(),
+ )
+ .expect("prepared assets");
+ let plan = assets.to_plan();
+
+ assert_eq!(assets.visuals.len(), 1);
+ assert_eq!(assets.visuals[0].texture_count, 2);
+ assert_eq!(assets.visuals[0].lightmap_count, 2);
+ assert_eq!(assets.textures.len(), 2);
+ assert_eq!(report.texture_count, 2);
+ assert_eq!((plan.texture_count, plan.lightmap_count), (2, 2));
+ }
+
+ #[test]
fn asset_preparation_limits_reject_texture_pixel_budget() {
let (repo, prototypes) = collision_fixture();
@@ -6772,6 +8647,165 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
+ fn tag17_bareobject_controls_preserve_explicit_assets_and_real_effect_rows() {
+ let root = fixture_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
+ let repository = CachedResourceRepository::new(Arc::clone(&vfs));
+ let cases = [
+ (
+ "bm_m_01",
+ "bm_m_01.ctl",
+ "bm_m_a.cpt",
+ "bm_m_01.ndp",
+ "e45658e9f3a6b72519eb47bd03e3f41ebde12ab361361297ccb916d0aed5894d",
+ 0x3F66_6666,
+ 0x458C_A000,
+ ["engine_mis_m", "smoke_m_ms_01", "gunf_missile_m"],
+ ),
+ (
+ "bm_l_01",
+ "bm_l_01.ctl",
+ "bm_l_a.cpt",
+ "bm_l_01.ndp",
+ "14fadfa27a4d4eab6ab9758f76a89031e085e880b42085219fa117562ab398cc",
+ 0x3F59_999A,
+ 0x457A_0000,
+ ["engine_mis_l", "smoke_l_ms_01", "gunf_missile_l"],
+ ),
+ ];
+
+ for (
+ root_name,
+ control_name,
+ point_name,
+ ndp_name,
+ payload_hash,
+ word11_bits,
+ word13_bits,
+ names,
+ ) in cases
+ {
+ let prototypes = resolve_prototype_all(
+ &repository,
+ vfs.as_ref(),
+ &resource_name(root_name.as_bytes()),
+ )
+ .expect("resolve the original bare-object registry entry");
+ assert_eq!(prototypes.len(), 1, "{root_name}");
+ let prototype = &prototypes[0];
+ let control_source = prototype
+ .control_resource
+ .clone()
+ .expect("bare object has an explicit CTL reference");
+ let point_source = prototype
+ .point_resource
+ .clone()
+ .expect("bare object has an explicit CPT reference");
+ let ndp_source = prototype
+ .node_data_resource
+ .clone()
+ .expect("bare object has an explicit NDP reference");
+ assert_eq!(control_source.archive.as_str(), "weapon.rlb");
+ assert_eq!(control_source.name, resource_name(control_name.as_bytes()));
+ assert_eq!(point_source.archive.as_str(), "weapon.rlb");
+ assert_eq!(point_source.name, resource_name(point_name.as_bytes()));
+ assert_eq!(ndp_source.archive.as_str(), "weapon.rlb");
+ assert_eq!(ndp_source.name, resource_name(ndp_name.as_bytes()));
+
+ let archive_id = repository
+ .open_archive(&control_source.archive)
+ .expect("open the referenced CTL archive");
+ let entry = repository
+ .find(archive_id, &control_source.name)
+ .expect("find the referenced CTL")
+ .expect("referenced CTL exists");
+ let payload = repository.read(entry).expect("read the referenced CTL");
+ assert_eq!(
+ sha256_hex(&sha256(payload.as_slice())),
+ payload_hash,
+ "{root_name} CTL payload hash"
+ );
+
+ let assets = prepare_mission_assets_with_repository(
+ &repository,
+ &[0..prototypes.len()],
+ &prototypes,
+ )
+ .expect("prepare the resolved object through the production asset path");
+ assert_eq!(assets.object_component_controls.len(), 1, "{root_name}");
+ assert_eq!(assets.object_component_controls[0].len(), 1, "{root_name}");
+ let control = assets.object_component_controls[0][0]
+ .as_ref()
+ .expect("the resolved component has prepared control");
+ assert_eq!(control.source, control_source, "{root_name} CTL source");
+ let point_list = control
+ .point_list
+ .as_ref()
+ .expect("the parsed effect rows load the explicitly registered CPT");
+ assert_eq!(point_list.source, point_source, "{root_name} CPT source");
+ assert!(
+ point_list.points.len() >= 3,
+ "{root_name} effect point indices"
+ );
+ let node_data = control
+ .node_data
+ .as_ref()
+ .expect("the explicit NDP remains attached");
+ assert_eq!(node_data.source, ndp_source, "{root_name} NDP source");
+ assert_eq!(node_data.rows.len(), 1, "{root_name} NDP rows");
+
+ assert_eq!(control.typed_records.len(), 1, "{root_name}");
+ let record = &control.typed_records[0];
+ assert_eq!(record.tag, 17, "{root_name}");
+ let mut expected_fixed_words = [0_u32; 44];
+ expected_fixed_words[0] = 17;
+ expected_fixed_words[3..=6].fill(u32::MAX);
+ expected_fixed_words[9] = 0x3F80_0000;
+ expected_fixed_words[10] = 0x3F80_0000;
+ expected_fixed_words[11] = word11_bits;
+ expected_fixed_words[12] = 0x43FA_0000;
+ expected_fixed_words[13] = word13_bits;
+ assert_eq!(
+ record.fixed_words, expected_fixed_words,
+ "{root_name} raw words"
+ );
+ assert!(record.values.is_empty(), "{root_name} values");
+ assert!(record.string_bytes.is_empty(), "{root_name} string");
+
+ let expected_rows = names
+ .iter()
+ .enumerate()
+ .map(|(row_index, name)| {
+ let mut raw_name = fixed_name(name);
+ match row_index {
+ 0 => raw_name[13..15].copy_from_slice(b"rd"),
+ 1 => raw_name[14..16].copy_from_slice(b"rd"),
+ _ => {}
+ }
+ PreparedControlEffectRow {
+ table_index: 0,
+ row_index,
+ raw_condition: [0; 3],
+ point_offsets: [0, 1, 2],
+ selector: u32::try_from(row_index + 1).expect("small effect selector"),
+ resource: PreparedControlResourcePair {
+ archive: fixed_name("effects.rlb"),
+ name: raw_name,
+ },
+ }
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(control.initial_effect_group, Some(0), "{root_name}");
+ assert_eq!(
+ control.effect_section,
+ PreparedControlEffectSection::Parsed(expected_rows),
+ "{root_name} parsed type-4 rows"
+ );
+ }
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
fn prepares_real_unit_asset_plan() {
let root = fixture_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
diff --git a/crates/fparkan-assets/src/research.rs b/crates/fparkan-assets/src/research.rs
new file mode 100644
index 0000000..b261928
--- /dev/null
+++ b/crates/fparkan-assets/src/research.rs
@@ -0,0 +1,1151 @@
+use crate::{decode_nres_payload, NresDocument};
+use fparkan_binary::{checked_allocation_len, checked_count_bytes, Cursor, DecodeError, Limits};
+use fparkan_nres::{EntryId, NresEntry};
+use fparkan_path::NormalizedPath;
+use fparkan_prototype::{
+ decode_unit_dat, unit_component_tree, PrototypeError, UnitComponentRecord,
+ UnitComponentTreeNode, UnitDat,
+};
+use fparkan_vfs::Vfs;
+use std::collections::HashMap;
+use std::fmt;
+use std::sync::Arc;
+
+const TRF0: u32 = u32::from_le_bytes(*b"TRF0");
+const TRF1: u32 = u32::from_le_bytes(*b"TRF1");
+const TRF2: u32 = u32::from_le_bytes(*b"TRF2");
+const TRF3: u32 = u32::from_le_bytes(*b"TRF3");
+const TRF4: u32 = u32::from_le_bytes(*b"TRF4");
+const TRF5: u32 = u32::from_le_bytes(*b"TRF5");
+const TRF6: u32 = u32::from_le_bytes(*b"TRF6");
+const TRF7: u32 = u32::from_le_bytes(*b"TRF7");
+const TRF8: u32 = u32::from_le_bytes(*b"TRF8");
+const TRF9: u32 = u32::from_le_bytes(*b"TRF9");
+const TRFA: u32 = u32::from_le_bytes(*b"TRFA");
+const TRFB: u32 = u32::from_le_bytes(*b"TRFB");
+const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1;
+const UNIT_DAT_HEADER_BYTES: usize = 8;
+const UNIT_DAT_RECORD_BYTES: usize = 112;
+const INITIAL_RESEARCH_COST_BOUND: f32 = f32::from_bits(0x4b18_967f);
+
+const RESEARCH_CHUNKS: [u32; 12] = [
+ TRF0, TRF1, TRF2, TRF3, TRF4, TRF5, TRF6, TRF7, TRF8, TRF9, TRFA, TRFB,
+];
+
+/// Ore and power outputs from one native CalcFullResearchCost call.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ResearchCost {
+ /// First native output, labeled ore by the Calc debug string.
+ pub ore: f32,
+ /// Second native output, labeled power by the Calc debug string.
+ pub power: f32,
+}
+
+/// One decoded MisLoad research table and its original NRes archive.
+#[derive(Debug)]
+pub struct ResearchTrfDocument {
+ archive: NresDocument,
+ records: Vec<[u32; 10]>,
+ flags: Vec<u8>,
+ aliases: Vec<ResearchAlias>,
+ name_pool: Vec<u8>,
+ relation_2_3: ResearchRelations,
+ relation_4_5: ResearchRelations,
+}
+
+/// One ordered research candidate and its native per-entry status byte.
+#[derive(Clone, Debug)]
+pub struct ResearchCandidate {
+ /// Unit DAT path read through the caller's VFS.
+ pub path: NormalizedPath,
+ /// Native status: zero entries are considered by the next refresh.
+ pub status: u8,
+}
+
+/// Mutable candidate catalog and selected-index state for the research chooser.
+#[derive(Clone, Debug, Default)]
+pub struct ResearchCandidateSet {
+ /// Candidate entries in native supplied order.
+ pub entries: Vec<ResearchCandidate>,
+ /// Selected candidate index, preserved when no lower-cost winner exists.
+ pub selected_index: u32,
+ /// Whether the candidate catalog has been loaded.
+ pub loaded: bool,
+ /// Native candidate-set scan status byte.
+ pub status: u8,
+}
+
+impl ResearchCandidateSet {
+ /// Refreshes this set's native selector state from the supplied research table.
+ ///
+ /// # Errors
+ ///
+ /// Returns explicit errors for unsupported lookup semantics or malformed
+ /// research-table fields reached by the predicates.
+ /// Missing paths, invalid candidate DAT framing and an empty-table cost
+ /// failure skip only that entry.
+ pub fn refresh_selection(
+ &mut self,
+ vfs: &dyn Vfs,
+ research: Option<&ResearchTrfDocument>,
+ ) -> Result<(), ResearchError> {
+ let Some(research) = research else {
+ return Ok(());
+ };
+ if !self.loaded {
+ return Ok(());
+ }
+
+ self.status = 1;
+ let mut running_bound = INITIAL_RESEARCH_COST_BOUND;
+ for index in 0..self.entries.len() {
+ if self.entries[index].status != 0 {
+ continue;
+ }
+
+ self.status = 0;
+ let Ok(bytes) = vfs.read(&self.entries[index].path) else {
+ continue;
+ };
+ let Ok((candidate, tree)) = decode_candidate_first_tree(&bytes) else {
+ continue;
+ };
+
+ let first = first_research_predicate(&candidate, &tree, research)?;
+ self.entries[index].status = u8::from(first);
+ if !first {
+ continue;
+ }
+
+ let second = second_research_predicate(&candidate, &tree, research)?;
+ self.entries[index].status = u8::from(second);
+ let cost = match calc_full_research_cost(&candidate, research) {
+ Ok(cost) => cost,
+ Err(ResearchError::EmptyResearchTable) => continue,
+ Err(error) => return Err(error),
+ };
+ if !second && cost.ore < running_bound {
+ let selected_index = u32::try_from(index)
+ .map_err(|_| ResearchError::Binary(DecodeError::IntegerOverflow))?;
+ running_bound = cost.ore;
+ self.selected_index = selected_index;
+ }
+ }
+ Ok(())
+ }
+}
+
+#[derive(Clone, Debug)]
+struct ResearchAlias {
+ name_range: std::ops::Range<usize>,
+ row_index: u16,
+}
+
+#[derive(Debug)]
+struct ResearchRelations {
+ table_id: u32,
+ group_starts: Vec<usize>,
+ indices: Vec<u32>,
+}
+
+#[derive(Debug)]
+struct Chunk<'a> {
+ entry: &'a NresEntry,
+ payload: &'a [u8],
+}
+
+/// TRF decoding and cost-calculation error.
+#[derive(Debug)]
+pub enum ResearchError {
+ /// The outer NRes archive is malformed.
+ Nres(fparkan_nres::NresError),
+ /// A bounded TRF field is truncated or overflows.
+ Binary(DecodeError),
+ /// A native-required TRF chunk is absent.
+ MissingChunk { type_id: u32 },
+ /// A recognized TRF chunk occurs more than once.
+ DuplicateChunk { type_id: u32 },
+ /// The native loader requires TRF0 attribute 2 to equal three.
+ InvalidTrf0Attribute { got: u32 },
+ /// A TRF1 payload is too large for the native zero-initialized flag buffer.
+ FlagsTooLong { row_count: usize, actual: usize },
+ /// A TRFB pair points outside the TRF6 name pool.
+ InvalidNameOffset { pair_index: usize, offset: u16 },
+ /// A TRF6 string does not end in NUL before the pool ends.
+ UnterminatedName { pair_index: usize, offset: u16 },
+ /// A TRF0 row's low 16-bit pair link is outside TRFB.
+ InvalidRecordPair {
+ record_index: usize,
+ pair_index: u16,
+ pair_count: usize,
+ },
+ /// A key or relation resolves to a research row outside TRF0.
+ InvalidResearchIndex {
+ type_id: u32,
+ index: u32,
+ row_count: usize,
+ },
+ /// A relation table has no group at this research row.
+ MissingRelationGroup {
+ type_id: u32,
+ index: usize,
+ group_count: usize,
+ },
+ /// A required non-empty relation stream is absent.
+ MissingRelationStream { type_id: u32, required_ids: u64 },
+ /// A candidate Unit DAT resource field is not NUL-terminated.
+ MissingCandidateKeyTerminator { component_index: usize },
+ /// No TRFB key matches an enabled candidate component.
+ UnknownResearchKey {
+ component_index: usize,
+ key: Vec<u8>,
+ },
+ /// A non-ASCII lookup would require an unverified locale-specific fold.
+ UnsupportedNonAsciiLookup {
+ query: Vec<u8>,
+ alias: Option<Vec<u8>>,
+ },
+ /// The research table has no rows.
+ EmptyResearchTable,
+ /// The candidate Unit DAT has no root component.
+ EmptyCandidateTree,
+ /// The existing Unit DAT tree builder rejected the child-count framing.
+ CandidateTree(PrototypeError),
+}
+
+impl fmt::Display for ResearchError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Nres(source) => write!(formatter, "{source}"),
+ Self::Binary(source) => write!(formatter, "{source}"),
+ Self::MissingChunk { type_id } => {
+ write!(formatter, "research NRes is missing chunk {type_id:#010x}")
+ }
+ Self::DuplicateChunk { type_id } => {
+ write!(formatter, "research NRes has duplicate chunk {type_id:#010x}")
+ }
+ Self::InvalidTrf0Attribute { got } => {
+ write!(formatter, "TRF0 attribute 2 is {got}, expected 3")
+ }
+ Self::FlagsTooLong { row_count, actual } => write!(
+ formatter,
+ "TRF1 has {actual} flags, exceeding the {row_count}-byte native buffer"
+ ),
+ Self::InvalidNameOffset { pair_index, offset } => write!(
+ formatter,
+ "TRFB pair {pair_index} name offset {offset} is outside TRF6"
+ ),
+ Self::UnterminatedName { pair_index, offset } => write!(
+ formatter,
+ "TRFB pair {pair_index} name at TRF6 offset {offset} is not NUL-terminated"
+ ),
+ Self::InvalidRecordPair {
+ record_index,
+ pair_index,
+ pair_count,
+ } => write!(
+ formatter,
+ "TRF0 row {record_index} links to TRFB pair {pair_index}, but there are {pair_count} pairs"
+ ),
+ Self::InvalidResearchIndex {
+ type_id,
+ index,
+ row_count,
+ } => write!(
+ formatter,
+ "research index {index} from {type_id:#010x} exceeds TRF0 row count {row_count}"
+ ),
+ Self::MissingRelationGroup {
+ type_id,
+ index,
+ group_count,
+ } => write!(
+ formatter,
+ "{type_id:#010x} has no relation group for row {index} (group count {group_count})"
+ ),
+ Self::MissingRelationStream {
+ type_id,
+ required_ids,
+ } => write!(
+ formatter,
+ "{type_id:#010x} is absent but its count table requires {required_ids} indices"
+ ),
+ Self::MissingCandidateKeyTerminator { component_index } => write!(
+ formatter,
+ "candidate component {component_index} has no NUL in its 32-byte resource key"
+ ),
+ Self::UnknownResearchKey {
+ component_index,
+ key,
+ } => write!(
+ formatter,
+ "candidate component {component_index} key {:?} is absent from TRFB",
+ String::from_utf8_lossy(key)
+ ),
+ Self::UnsupportedNonAsciiLookup { query, alias } => {
+ if let Some(alias) = alias {
+ write!(
+ formatter,
+ "research key comparison between {:?} and {:?} needs unverified non-ASCII case folding",
+ String::from_utf8_lossy(query),
+ String::from_utf8_lossy(alias)
+ )
+ } else {
+ write!(
+ formatter,
+ "research key {:?} needs unverified non-ASCII case folding",
+ String::from_utf8_lossy(query)
+ )
+ }
+ }
+ Self::EmptyResearchTable => write!(formatter, "research table has no rows"),
+ Self::EmptyCandidateTree => write!(formatter, "candidate Unit DAT has no root component"),
+ Self::CandidateTree(source) => write!(formatter, "{source}"),
+ }
+ }
+}
+
+impl std::error::Error for ResearchError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Nres(source) => Some(source),
+ Self::Binary(source) => Some(source),
+ Self::CandidateTree(source) => Some(source),
+ Self::MissingChunk { .. }
+ | Self::DuplicateChunk { .. }
+ | Self::InvalidTrf0Attribute { .. }
+ | Self::FlagsTooLong { .. }
+ | Self::InvalidNameOffset { .. }
+ | Self::UnterminatedName { .. }
+ | Self::InvalidRecordPair { .. }
+ | Self::InvalidResearchIndex { .. }
+ | Self::MissingRelationGroup { .. }
+ | Self::MissingRelationStream { .. }
+ | Self::MissingCandidateKeyTerminator { .. }
+ | Self::UnknownResearchKey { .. }
+ | Self::UnsupportedNonAsciiLookup { .. }
+ | Self::EmptyResearchTable
+ | Self::EmptyCandidateTree => None,
+ }
+ }
+}
+
+impl From<fparkan_nres::NresError> for ResearchError {
+ fn from(value: fparkan_nres::NresError) -> Self {
+ Self::Nres(value)
+ }
+}
+
+impl From<DecodeError> for ResearchError {
+ fn from(value: DecodeError) -> Self {
+ Self::Binary(value)
+ }
+}
+
+impl From<PrototypeError> for ResearchError {
+ fn from(value: PrototypeError) -> Self {
+ Self::CandidateTree(value)
+ }
+}
+
+impl ResearchTrfDocument {
+ /// Decodes the native research table chunks from an NRes archive.
+ ///
+ /// NRes validates the archive framing and payload ranges. This reader then
+ /// validates only the TRF bytes needed by its row, key, and relation views.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error for malformed NRes framing, a missing required chunk,
+ /// an invalid TRF0 guard, or a referenced byte range outside its chunk.
+ pub fn decode(bytes: Arc<[u8]>) -> Result<Self, ResearchError> {
+ let archive = decode_nres_payload(bytes)?;
+ let mut chunk_ids = HashMap::new();
+ for entry in archive.entries() {
+ let type_id = entry.meta().type_id;
+ if RESEARCH_CHUNKS.contains(&type_id) && chunk_ids.insert(type_id, entry.id()).is_some()
+ {
+ return Err(ResearchError::DuplicateChunk { type_id });
+ }
+ }
+
+ let trf0 = required_chunk(&archive, &chunk_ids, TRF0)?;
+ if trf0.entry.meta().attr2 != 3 {
+ return Err(ResearchError::InvalidTrf0Attribute {
+ got: trf0.entry.meta().attr2,
+ });
+ }
+ let row_count = trf0.entry.meta().attr1;
+ let row_bytes = counted_prefix(trf0.payload, u64::from(row_count), 40)?;
+ let records = read_research_rows(row_bytes, row_count)?;
+
+ let trf1 = required_chunk(&archive, &chunk_ids, TRF1)?;
+ let flag_count = usize::try_from(row_count).map_err(|_| DecodeError::IntegerOverflow)?;
+ if trf1.payload.len() > flag_count {
+ return Err(ResearchError::FlagsTooLong {
+ row_count: flag_count,
+ actual: trf1.payload.len(),
+ });
+ }
+ let mut flags = vec![0; flag_count];
+ flags[..trf1.payload.len()].copy_from_slice(trf1.payload);
+
+ let trfb = required_chunk(&archive, &chunk_ids, TRFB)?;
+ let alias_count = trfb.entry.meta().attr1;
+ let pair_bytes = counted_prefix(trfb.payload, u64::from(alias_count), 4)?;
+ let trf6 = required_chunk(&archive, &chunk_ids, TRF6)?;
+ let name_pool = trf6.payload.to_vec();
+ let aliases = read_aliases(pair_bytes, &name_pool, alias_count)?;
+
+ for (record_index, record) in records.iter().enumerate() {
+ // The native reader casts the packed word to u16 before indexing TRFB.
+ let pair_index = record[8] as u16;
+ if usize::from(pair_index) >= aliases.len() {
+ return Err(ResearchError::InvalidRecordPair {
+ record_index,
+ pair_index,
+ pair_count: aliases.len(),
+ });
+ }
+ }
+
+ for type_id in [TRF7, TRF8, TRF9, TRFA] {
+ required_chunk(&archive, &chunk_ids, type_id)?;
+ }
+
+ let relation_2_3 = read_relations(&archive, &chunk_ids, TRF2, TRF3)?;
+ let relation_4_5 = read_relations(&archive, &chunk_ids, TRF4, TRF5)?;
+
+ Ok(Self {
+ archive,
+ records,
+ flags,
+ aliases,
+ name_pool,
+ relation_2_3,
+ relation_4_5,
+ })
+ }
+
+ /// Returns the retained NRes document, including opaque chunks and unused payload tails.
+ #[must_use]
+ pub fn archive(&self) -> &NresDocument {
+ &self.archive
+ }
+
+ /// Number of TRF0 rows, taken from that chunk's own attr1.
+ #[must_use]
+ pub fn row_count(&self) -> usize {
+ self.records.len()
+ }
+
+ /// Looks up the first ASCII-case-insensitive TRFB alias in source order.
+ ///
+ /// The shipped research keys are ASCII and use ASCII case-insensitive matching.
+ /// Comparisons involving non-ASCII bytes fail explicitly because native
+ /// locale-specific folding for those bytes is unknown.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if matching requires unverified non-ASCII folding or
+ /// the selected alias contains an out-of-range row id.
+ pub fn find_key(&self, key: &[u8]) -> Result<Option<usize>, ResearchError> {
+ let key = key.split(|byte| *byte == 0).next().unwrap_or_default();
+ if !key.is_ascii() {
+ return Err(ResearchError::UnsupportedNonAsciiLookup {
+ query: key.to_vec(),
+ alias: None,
+ });
+ }
+ for alias in &self.aliases {
+ let name = &self.name_pool[alias.name_range.clone()];
+ if !name.is_ascii() {
+ return Err(ResearchError::UnsupportedNonAsciiLookup {
+ query: key.to_vec(),
+ alias: Some(name.to_vec()),
+ });
+ }
+ if name.eq_ignore_ascii_case(key) {
+ let row_index = u32::from(alias.row_index);
+ if usize::from(alias.row_index) >= self.records.len() {
+ return Err(ResearchError::InvalidResearchIndex {
+ type_id: TRFB,
+ index: row_index,
+ row_count: self.records.len(),
+ });
+ }
+ return Ok(Some(usize::from(alias.row_index)));
+ }
+ }
+ Ok(None)
+ }
+
+ /// Returns the preserved ten raw TRF0 words for one row.
+ #[must_use]
+ pub fn row_words(&self, index: usize) -> Option<&[u32; 10]> {
+ self.records.get(index)
+ }
+
+ /// Returns the original TRF1 byte for one row.
+ #[must_use]
+ pub fn flag_byte(&self, index: usize) -> Option<u8> {
+ self.flags.get(index).copied()
+ }
+
+ /// Returns the raw name bytes selected by a row's low-16-bit TRFB link.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error when the row is outside TRF0.
+ pub fn row_name(&self, index: usize) -> Result<&[u8], ResearchError> {
+ let row = self
+ .records
+ .get(index)
+ .ok_or(ResearchError::InvalidResearchIndex {
+ type_id: TRF0,
+ index: u32::try_from(index).unwrap_or(u32::MAX),
+ row_count: self.records.len(),
+ })?;
+ let pair_index = usize::from(row[8] as u16);
+ let alias = self
+ .aliases
+ .get(pair_index)
+ .ok_or(ResearchError::InvalidRecordPair {
+ record_index: index,
+ pair_index: row[8] as u16,
+ pair_count: self.aliases.len(),
+ })?;
+ Ok(&self.name_pool[alias.name_range.clone()])
+ }
+
+ /// Returns the ordered TRF2/TRF3 indices used by CalcFullResearchCost.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if this row has no TRF2 group or its TRF3 stream
+ /// contains a row index outside TRF0.
+ pub fn relation_2_3(&self, index: usize) -> Result<&[u32], ResearchError> {
+ let related = self.relation_2_3.get(index)?;
+ validate_relation_indices(related, TRF3, self.records.len())?;
+ Ok(related)
+ }
+
+ /// Returns the separate ordered TRF4/TRF5 index group.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if this row has no TRF4 group or its TRF5 stream
+ /// contains a row index outside TRF0.
+ pub fn relation_4_5(&self, index: usize) -> Result<&[u32], ResearchError> {
+ let related = self.relation_4_5.get(index)?;
+ validate_relation_indices(related, TRF5, self.records.len())?;
+ Ok(related)
+ }
+
+ fn gates(&self, index: usize) -> Result<(u32, u32, u32), ResearchError> {
+ let flag = self
+ .flags
+ .get(index)
+ .copied()
+ .ok_or(ResearchError::InvalidResearchIndex {
+ type_id: TRF1,
+ index: u32::try_from(index).unwrap_or(u32::MAX),
+ row_count: self.records.len(),
+ })?;
+ Ok((
+ u32::from((flag >> 1) & 1),
+ u32::from(flag & 1),
+ u32::from((flag >> 2) & 1),
+ ))
+ }
+}
+
+pub(super) fn decode_candidate_first_tree(
+ payload: &[u8],
+) -> Result<(UnitDat, Vec<UnitComponentTreeNode>), PrototypeError> {
+ let limits = Limits::default();
+ let payload_len = u64::try_from(payload.len()).map_err(|_| DecodeError::IntegerOverflow)?;
+ if payload_len > limits.max_file_bytes {
+ return Err(DecodeError::LimitExceeded {
+ count: payload_len,
+ limit: limits.max_file_bytes,
+ }
+ .into());
+ }
+ if payload.len() < UNIT_DAT_HEADER_BYTES {
+ return Err(PrototypeError::InvalidSize);
+ }
+ let magic = u32::from_le_bytes(
+ payload[..4]
+ .try_into()
+ .map_err(|_| PrototypeError::InvalidSize)?,
+ );
+ if magic != UNIT_DAT_MAGIC {
+ return Err(PrototypeError::InvalidUnitDatMagic(magic));
+ }
+
+ let available_body = payload.len() - UNIT_DAT_HEADER_BYTES;
+ let complete_records = available_body / UNIT_DAT_RECORD_BYTES;
+ if complete_records == 0 {
+ return Err(PrototypeError::InvalidSize);
+ }
+ let maximum_records =
+ usize::try_from(limits.max_entries).map_err(|_| DecodeError::IntegerOverflow)?;
+ let scan_records = complete_records.min(maximum_records);
+ let mut pending_nodes = 1_u64;
+ let mut first_tree_count = None;
+
+ for record_index in 0..scan_records {
+ let record_offset = record_index
+ .checked_mul(UNIT_DAT_RECORD_BYTES)
+ .and_then(|offset| UNIT_DAT_HEADER_BYTES.checked_add(offset))
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let children_offset = record_offset
+ .checked_add(108)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let children_end = children_offset
+ .checked_add(4)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let child_count = u32::from_le_bytes(
+ payload
+ .get(children_offset..children_end)
+ .ok_or(PrototypeError::InvalidSize)?
+ .try_into()
+ .map_err(|_| PrototypeError::InvalidSize)?,
+ );
+ pending_nodes = pending_nodes
+ .checked_sub(1)
+ .and_then(|pending| pending.checked_add(u64::from(child_count)))
+ .ok_or(DecodeError::IntegerOverflow)?;
+ if pending_nodes > u64::from(limits.max_entries) {
+ return Err(DecodeError::LimitExceeded {
+ count: pending_nodes,
+ limit: u64::from(limits.max_entries),
+ }
+ .into());
+ }
+ if pending_nodes == 0 {
+ first_tree_count = Some(
+ record_index
+ .checked_add(1)
+ .ok_or(DecodeError::IntegerOverflow)?,
+ );
+ break;
+ }
+ }
+
+ let Some(record_count) = first_tree_count else {
+ if scan_records == maximum_records {
+ return Err(DecodeError::LimitExceeded {
+ count: u64::from(limits.max_entries) + 1,
+ limit: u64::from(limits.max_entries),
+ }
+ .into());
+ }
+ return Err(PrototypeError::InvalidSize);
+ };
+ let record_count = u64::try_from(record_count).map_err(|_| DecodeError::IntegerOverflow)?;
+ let _record_capacity = checked_allocation_len(record_count, u64::from(limits.max_entries))?;
+ let record_bytes = checked_count_bytes(
+ record_count,
+ UNIT_DAT_RECORD_BYTES as u64,
+ u64::try_from(available_body).map_err(|_| DecodeError::IntegerOverflow)?,
+ )?;
+ let record_size = u64::try_from(std::mem::size_of::<UnitComponentRecord>())
+ .map_err(|_| DecodeError::IntegerOverflow)?;
+ let tree_record_size = std::mem::size_of::<UnitComponentTreeNode>()
+ .checked_add(2 * std::mem::size_of::<usize>())
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let tree_record_size =
+ u64::try_from(tree_record_size).map_err(|_| DecodeError::IntegerOverflow)?;
+ let decoded_bytes = record_size
+ .checked_add(tree_record_size)
+ .and_then(|size| size.checked_mul(record_count))
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let _decoded_capacity = checked_allocation_len(decoded_bytes, limits.max_decoded_bytes)?;
+ let prefix_end = UNIT_DAT_HEADER_BYTES
+ .checked_add(record_bytes)
+ .ok_or(DecodeError::IntegerOverflow)?;
+
+ let candidate = decode_unit_dat(&payload[..prefix_end])?;
+ let tree = unit_component_tree(&candidate.records)?;
+ Ok((candidate, tree))
+}
+
+fn candidate_research_predicate(
+ candidate: &UnitDat,
+ tree: &[UnitComponentTreeNode],
+ research: &ResearchTrfDocument,
+ first_info_word: usize,
+ second_info_word: usize,
+) -> Result<bool, ResearchError> {
+ let mut stack = vec![0_usize];
+ while let Some(node_index) = stack.pop() {
+ let Some(node) = tree.get(node_index) else {
+ return Err(ResearchError::CandidateTree(
+ PrototypeError::InvalidUnitHierarchy(format!(
+ "component index {node_index} is outside the reconstructed tree"
+ )),
+ ));
+ };
+ let Some(component) = candidate.records.get(node.record_index) else {
+ return Err(ResearchError::CandidateTree(
+ PrototypeError::InvalidUnitHierarchy(format!(
+ "tree references missing component {}",
+ node.record_index
+ )),
+ ));
+ };
+ if component.kind == 0 {
+ continue;
+ }
+
+ let Some(key_end) = component.resource_raw.iter().position(|byte| *byte == 0) else {
+ return Err(ResearchError::MissingCandidateKeyTerminator {
+ component_index: node.record_index,
+ });
+ };
+ let Some(research_index) = research.find_key(&component.resource_raw[..key_end])? else {
+ return Ok(false);
+ };
+ let (word0, word1, word2) = research.gates(research_index)?;
+ let info_words = [word0, word1, word2];
+ if info_words[first_info_word] == 0 || info_words[second_info_word] == 0 {
+ return Ok(false);
+ }
+ for child in node.children.iter().rev() {
+ stack.push(*child);
+ }
+ }
+ Ok(true)
+}
+
+fn first_research_predicate(
+ candidate: &UnitDat,
+ tree: &[UnitComponentTreeNode],
+ research: &ResearchTrfDocument,
+) -> Result<bool, ResearchError> {
+ candidate_research_predicate(candidate, tree, research, 1, 2)
+}
+
+fn second_research_predicate(
+ candidate: &UnitDat,
+ tree: &[UnitComponentTreeNode],
+ research: &ResearchTrfDocument,
+) -> Result<bool, ResearchError> {
+ candidate_research_predicate(candidate, tree, research, 0, 2)
+}
+
+/// Calculates the ordered ore and power cost for one candidate Unit DAT tree.
+///
+/// # Errors
+///
+/// Returns an error for an empty research table, malformed candidate tree,
+/// missing or unsupported key comparison, invalid relation group, or invalid
+/// row reference.
+pub fn calc_full_research_cost(
+ candidate: &UnitDat,
+ research: &ResearchTrfDocument,
+) -> Result<ResearchCost, ResearchError> {
+ if research.records.is_empty() {
+ return Err(ResearchError::EmptyResearchTable);
+ }
+ let tree = unit_component_tree(&candidate.records)?;
+ if tree.is_empty() {
+ return Err(ResearchError::EmptyCandidateTree);
+ }
+
+ let mut visited = vec![false; research.records.len()];
+ let direct = collect_direct_indices(candidate, &tree, research, &mut visited)?;
+ let mut closure = Vec::new();
+ for &index in &direct {
+ closure.push(index);
+ let (_, recurse, _) = research.gates(index)?;
+ if recurse == 0 {
+ append_relation_closure(index, research, &mut visited, &mut closure)?;
+ }
+ }
+
+ let mut ore = 0.0_f32;
+ let mut power = 0.0_f32;
+ for index in closure {
+ let (included, _, _) = research.gates(index)?;
+ if included == 0 {
+ let row = research
+ .records
+ .get(index)
+ .ok_or(ResearchError::InvalidResearchIndex {
+ type_id: TRF0,
+ index: u32::try_from(index).unwrap_or(u32::MAX),
+ row_count: research.records.len(),
+ })?;
+ ore = ore + f32::from_bits(row[1]);
+ power = power + f32::from_bits(row[0]);
+ }
+ }
+
+ Ok(ResearchCost { ore, power })
+}
+
+fn collect_direct_indices(
+ candidate: &UnitDat,
+ tree: &[UnitComponentTreeNode],
+ research: &ResearchTrfDocument,
+ visited: &mut [bool],
+) -> Result<Vec<usize>, ResearchError> {
+ let mut direct = Vec::new();
+ let mut stack = vec![0_usize];
+ let mut first_error = None;
+
+ while let Some(node_index) = stack.pop() {
+ let Some(node) = tree.get(node_index) else {
+ return Err(ResearchError::CandidateTree(
+ PrototypeError::InvalidUnitHierarchy(format!(
+ "component index {node_index} is outside the reconstructed tree"
+ )),
+ ));
+ };
+ let Some(component) = candidate.records.get(node.record_index) else {
+ return Err(ResearchError::CandidateTree(
+ PrototypeError::InvalidUnitHierarchy(format!(
+ "tree references missing component {}",
+ node.record_index
+ )),
+ ));
+ };
+
+ if component.kind == 0 {
+ continue;
+ }
+
+ let key_end = component.resource_raw.iter().position(|byte| *byte == 0);
+ let key = match key_end {
+ Some(end) => &component.resource_raw[..end],
+ None => {
+ if first_error.is_none() {
+ first_error = Some(ResearchError::MissingCandidateKeyTerminator {
+ component_index: node.record_index,
+ });
+ }
+ for child in node.children.iter().rev() {
+ stack.push(*child);
+ }
+ continue;
+ }
+ };
+
+ match research.find_key(key) {
+ Ok(Some(index)) => match research.gates(index) {
+ Ok((included, _, _)) => {
+ if included == 0 && !visited[index] {
+ visited[index] = true;
+ direct.push(index);
+ }
+ }
+ Err(error) => {
+ if first_error.is_none() {
+ first_error = Some(error);
+ }
+ }
+ },
+ Ok(None) => {
+ if first_error.is_none() {
+ first_error = Some(ResearchError::UnknownResearchKey {
+ component_index: node.record_index,
+ key: key.to_vec(),
+ });
+ }
+ }
+ Err(error) => {
+ if first_error.is_none() {
+ first_error = Some(error);
+ }
+ }
+ }
+
+ for child in node.children.iter().rev() {
+ stack.push(*child);
+ }
+ }
+
+ if let Some(error) = first_error {
+ return Err(error);
+ }
+ Ok(direct)
+}
+
+#[derive(Clone, Copy)]
+struct RelationFrame {
+ row_index: usize,
+ next_child: usize,
+}
+
+fn append_relation_closure(
+ root: usize,
+ research: &ResearchTrfDocument,
+ visited: &mut [bool],
+ closure: &mut Vec<usize>,
+) -> Result<(), ResearchError> {
+ let mut stack = vec![RelationFrame {
+ row_index: root,
+ next_child: 0,
+ }];
+
+ while let Some(frame) = stack.last().copied() {
+ let related = research.relation_2_3.get(frame.row_index)?;
+ if frame.next_child >= related.len() {
+ stack.pop();
+ continue;
+ }
+ let related_index = related[frame.next_child];
+ stack
+ .last_mut()
+ .expect("non-empty relation stack")
+ .next_child += 1;
+
+ let index = usize::try_from(related_index).map_err(|_| DecodeError::IntegerOverflow)?;
+ if index >= research.records.len() {
+ return Err(ResearchError::InvalidResearchIndex {
+ type_id: TRF3,
+ index: related_index,
+ row_count: research.records.len(),
+ });
+ }
+ let (included, recurse, _) = research.gates(index)?;
+ if included != 0 || visited[index] {
+ continue;
+ }
+
+ visited[index] = true;
+ closure.push(index);
+ if recurse == 0 {
+ stack.push(RelationFrame {
+ row_index: index,
+ next_child: 0,
+ });
+ }
+ }
+
+ Ok(())
+}
+
+impl ResearchRelations {
+ fn get(&self, index: usize) -> Result<&[u32], ResearchError> {
+ let group_count = self.group_starts.len().saturating_sub(1);
+ let missing_group = || ResearchError::MissingRelationGroup {
+ type_id: self.table_id,
+ index,
+ group_count,
+ };
+ let Some(end_index) = index.checked_add(1) else {
+ return Err(missing_group());
+ };
+ let Some(&start) = self.group_starts.get(index) else {
+ return Err(missing_group());
+ };
+ let Some(&end) = self.group_starts.get(end_index) else {
+ return Err(missing_group());
+ };
+ Ok(&self.indices[start..end])
+ }
+}
+
+fn validate_relation_indices(
+ related: &[u32],
+ type_id: u32,
+ row_count: usize,
+) -> Result<(), ResearchError> {
+ for &index in related {
+ if usize::try_from(index).map_or(true, |value| value >= row_count) {
+ return Err(ResearchError::InvalidResearchIndex {
+ type_id,
+ index,
+ row_count,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn required_chunk<'a>(
+ archive: &'a NresDocument,
+ chunk_ids: &HashMap<u32, EntryId>,
+ type_id: u32,
+) -> Result<Chunk<'a>, ResearchError> {
+ let id = chunk_ids
+ .get(&type_id)
+ .copied()
+ .ok_or(ResearchError::MissingChunk { type_id })?;
+ let entry = archive
+ .entry(id)
+ .ok_or(fparkan_nres::NresError::EntryIdOutOfRange {
+ id: id.0,
+ entry_count: archive.header().entry_count,
+ })?;
+ let payload = archive.payload(id)?;
+ Ok(Chunk { entry, payload })
+}
+
+fn optional_chunk<'a>(
+ archive: &'a NresDocument,
+ chunk_ids: &HashMap<u32, EntryId>,
+ type_id: u32,
+) -> Result<Option<Chunk<'a>>, ResearchError> {
+ let Some(id) = chunk_ids.get(&type_id).copied() else {
+ return Ok(None);
+ };
+ let entry = archive
+ .entry(id)
+ .ok_or(fparkan_nres::NresError::EntryIdOutOfRange {
+ id: id.0,
+ entry_count: archive.header().entry_count,
+ })?;
+ let payload = archive.payload(id)?;
+ Ok(Some(Chunk { entry, payload }))
+}
+
+fn counted_prefix(payload: &[u8], count: u64, stride: u64) -> Result<&[u8], ResearchError> {
+ let remaining = u64::try_from(payload.len()).map_err(|_| DecodeError::IntegerOverflow)?;
+ let size = checked_count_bytes(count, stride, remaining)?;
+ Ok(&payload[..size])
+}
+
+fn read_research_rows(bytes: &[u8], row_count: u32) -> Result<Vec<[u32; 10]>, ResearchError> {
+ let capacity = checked_allocation_len(
+ u64::from(row_count),
+ u64::from(Limits::default().max_array_items),
+ )?;
+ let mut cursor = Cursor::new(bytes);
+ let mut records = Vec::with_capacity(capacity);
+ for _ in 0..row_count {
+ let mut row = [0_u32; 10];
+ for word in &mut row {
+ *word = cursor.read_u32_le()?;
+ }
+ records.push(row);
+ }
+ cursor.require_eof()?;
+ Ok(records)
+}
+
+fn read_aliases(
+ bytes: &[u8],
+ name_pool: &[u8],
+ alias_count: u32,
+) -> Result<Vec<ResearchAlias>, ResearchError> {
+ let capacity = checked_allocation_len(
+ u64::from(alias_count),
+ u64::from(Limits::default().max_array_items),
+ )?;
+ let mut cursor = Cursor::new(bytes);
+ let mut aliases = Vec::with_capacity(capacity);
+ for pair_index in 0..capacity {
+ let name_offset = cursor.read_u16_le()?;
+ let row_index = cursor.read_u16_le()?;
+ let offset = usize::from(name_offset);
+ let suffix = name_pool
+ .get(offset..)
+ .ok_or(ResearchError::InvalidNameOffset {
+ pair_index,
+ offset: name_offset,
+ })?;
+ let length =
+ suffix
+ .iter()
+ .position(|byte| *byte == 0)
+ .ok_or(ResearchError::UnterminatedName {
+ pair_index,
+ offset: name_offset,
+ })?;
+ aliases.push(ResearchAlias {
+ name_range: offset..offset + length,
+ row_index,
+ });
+ }
+ cursor.require_eof()?;
+ Ok(aliases)
+}
+
+fn read_relations(
+ archive: &NresDocument,
+ chunk_ids: &HashMap<u32, EntryId>,
+ count_type: u32,
+ index_type: u32,
+) -> Result<ResearchRelations, ResearchError> {
+ let count_chunk = required_chunk(archive, chunk_ids, count_type)?;
+ let count_count = count_chunk.entry.meta().attr1;
+ let count_bytes = counted_prefix(count_chunk.payload, u64::from(count_count), 4)?;
+ let mut cursor = Cursor::new(count_bytes);
+ let max_items = u64::from(Limits::default().max_array_items);
+ let group_count = u64::from(count_count);
+ let offset_count = group_count
+ .checked_add(1)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let offset_capacity = checked_allocation_len(
+ offset_count,
+ max_items
+ .checked_add(1)
+ .ok_or(DecodeError::IntegerOverflow)?,
+ )?;
+ let mut group_starts = Vec::with_capacity(offset_capacity);
+ group_starts.push(0_usize);
+ let mut total_ids = 0_u64;
+ for _ in 0..count_count {
+ let count = cursor.read_u32_le()?;
+ total_ids = total_ids
+ .checked_add(u64::from(count))
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let next = checked_allocation_len(total_ids, max_items)?;
+ group_starts.push(next);
+ }
+
+ let index_chunk = optional_chunk(archive, chunk_ids, index_type)?;
+ let indices = match index_chunk {
+ None if total_ids == 0 => Vec::new(),
+ None => {
+ return Err(ResearchError::MissingRelationStream {
+ type_id: index_type,
+ required_ids: total_ids,
+ })
+ }
+ Some(chunk) => {
+ let index_bytes = counted_prefix(chunk.payload, total_ids, 4)?;
+ let mut cursor = Cursor::new(index_bytes);
+ let capacity = checked_allocation_len(total_ids, max_items)?;
+ let mut indices = Vec::with_capacity(capacity);
+ for _ in 0..total_ids {
+ indices.push(cursor.read_u32_le()?);
+ }
+ cursor.require_eof()?;
+ indices
+ }
+ };
+
+ Ok(ResearchRelations {
+ table_id: count_type,
+ group_starts,
+ indices,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn decoded_array_limits_fail_before_large_allocations() {
+ let limit = Limits::default().max_array_items;
+ let too_many = limit.checked_add(1).expect("test count");
+
+ assert!(matches!(
+ read_research_rows(&[], too_many),
+ Err(ResearchError::Binary(DecodeError::LimitExceeded { count, limit: actual_limit }))
+ if count == u64::from(too_many) && actual_limit == u64::from(limit)
+ ));
+ assert!(matches!(
+ read_aliases(&[], &[], too_many),
+ Err(ResearchError::Binary(DecodeError::LimitExceeded { count, limit: actual_limit }))
+ if count == u64::from(too_many) && actual_limit == u64::from(limit)
+ ));
+ }
+}
diff --git a/crates/fparkan-font/Cargo.toml b/crates/fparkan-font/Cargo.toml
new file mode 100644
index 0000000..3053a3e
--- /dev/null
+++ b/crates/fparkan-font/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "fparkan-font"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-texm = { path = "../fparkan-texm" }
+
+[dev-dependencies]
+fparkan-nres = { path = "../fparkan-nres" }
diff --git a/crates/fparkan-font/src/lib.rs b/crates/fparkan-font/src/lib.rs
new file mode 100644
index 0000000..770ebb0
--- /dev/null
+++ b/crates/fparkan-font/src/lib.rs
@@ -0,0 +1,402 @@
+#![forbid(unsafe_code)]
+//! Safe reader for the fixed Tfnt metrics and its embedded Texm image.
+
+use fparkan_texm::{TexmDocument, TexmError};
+use std::sync::Arc;
+
+const PREFIX_LEN: usize = 0x14;
+const GLYPH_TABLE_END: usize = 0x1014;
+const GLYPH_COUNT: usize = 256;
+
+/// Confirmed Tfnt metrics and the preserved opaque word at offset `0x0c`.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct TfntHeader {
+ /// Lower bound used by the native fixed-width drawing path.
+ pub fixed_width_floor: i32,
+ /// Vertical extent used by native clipping and drawing.
+ pub vertical_extent: i32,
+ /// Raw bits copied from offset `0x0c`; its semantic name is unknown.
+ pub word_0c_bits: u32,
+ /// Per-input-byte spacing used by native text measurement.
+ pub character_spacing: i32,
+}
+
+/// One 16-byte glyph record; the first three words remain opaque.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct TfntGlyph {
+ /// First opaque DWORD.
+ pub word0: u32,
+ /// Second opaque DWORD.
+ pub word1: u32,
+ /// Third opaque DWORD.
+ pub word2: u32,
+ /// Signed advance at record offset `0x0c`.
+ pub advance: i32,
+}
+
+/// Decoded Tfnt prefix, fixed glyph table, and embedded Texm document.
+#[derive(Clone, Debug)]
+pub struct TfntDocument {
+ /// Header metrics.
+ pub header: TfntHeader,
+ /// Records indexed by the native unsigned input byte, `0..=255`.
+ pub glyphs: [TfntGlyph; GLYPH_COUNT],
+ /// Texture document parsed by the shared Texm reader.
+ pub texm: TexmDocument,
+}
+
+/// Tfnt decode error.
+#[derive(Debug)]
+pub enum TfntError {
+ /// The safe file reader requires the corpus signature `Tfnt`.
+ InvalidMagic { got: [u8; 4] },
+ /// The fixed 20-byte prefix is incomplete.
+ TruncatedHeader { actual: usize, required: usize },
+ /// The fixed 256-record table is incomplete.
+ TruncatedGlyphTable { actual: usize, required: usize },
+ /// The embedded texture could not be decoded.
+ Texm(TexmError),
+}
+
+impl std::fmt::Display for TfntError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::InvalidMagic { got } => write!(f, "invalid Tfnt magic: {got:02X?}"),
+ Self::TruncatedHeader { actual, required } => {
+ write!(f, "Tfnt header truncated: {actual} bytes, need {required}")
+ }
+ Self::TruncatedGlyphTable { actual, required } => {
+ write!(
+ f,
+ "Tfnt glyph table truncated: {actual} bytes, need {required}"
+ )
+ }
+ Self::Texm(source) => write!(f, "embedded Texm: {source}"),
+ }
+ }
+}
+
+impl std::error::Error for TfntError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Texm(source) => Some(source),
+ Self::InvalidMagic { .. }
+ | Self::TruncatedHeader { .. }
+ | Self::TruncatedGlyphTable { .. } => None,
+ }
+ }
+}
+
+/// Decodes a Tfnt file and its embedded Texm texture.
+///
+/// # Errors
+///
+/// Returns [`TfntError`] for a bad signature, an incomplete fixed header or
+/// glyph table, or an invalid embedded Texm document.
+pub fn decode_tfnt(bytes: Arc<[u8]>) -> Result<TfntDocument, TfntError> {
+ if bytes.len() < PREFIX_LEN {
+ return Err(TfntError::TruncatedHeader {
+ actual: bytes.len(),
+ required: PREFIX_LEN,
+ });
+ }
+
+ let mut magic = [0; 4];
+ magic.copy_from_slice(&bytes[..4]);
+ if magic != *b"Tfnt" {
+ return Err(TfntError::InvalidMagic { got: magic });
+ }
+
+ if bytes.len() < GLYPH_TABLE_END {
+ return Err(TfntError::TruncatedGlyphTable {
+ actual: bytes.len(),
+ required: GLYPH_TABLE_END,
+ });
+ }
+
+ let header = TfntHeader {
+ fixed_width_floor: read_i32_le(&bytes, 0x04),
+ vertical_extent: read_i32_le(&bytes, 0x08),
+ word_0c_bits: read_u32_le(&bytes, 0x0c),
+ character_spacing: read_i32_le(&bytes, 0x10),
+ };
+ let mut glyphs = [TfntGlyph::default(); GLYPH_COUNT];
+ for (glyph, record) in glyphs
+ .iter_mut()
+ .zip(bytes[PREFIX_LEN..GLYPH_TABLE_END].chunks_exact(16))
+ {
+ *glyph = TfntGlyph {
+ word0: read_u32_le(record, 0),
+ word1: read_u32_le(record, 4),
+ word2: read_u32_le(record, 8),
+ advance: read_i32_le(record, 12),
+ };
+ }
+
+ let texm =
+ fparkan_texm::decode_texm(Arc::from(&bytes[GLYPH_TABLE_END..])).map_err(TfntError::Texm)?;
+
+ Ok(TfntDocument {
+ header,
+ glyphs,
+ texm,
+ })
+}
+
+fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
+ u32::from_le_bytes(
+ bytes[offset..offset + 4]
+ .try_into()
+ .expect("fixed range was bounds-checked"),
+ )
+}
+
+fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
+ i32::from_le_bytes(
+ bytes[offset..offset + 4]
+ .try_into()
+ .expect("fixed range was bounds-checked"),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_nres::{decode as decode_nres, ReadProfile};
+ use fparkan_texm::PixelFormat;
+ use std::path::PathBuf;
+
+ #[test]
+ fn reads_distinct_header_values_and_preserves_nan_bits() {
+ let mut bytes = tfnt_prefix();
+ put_u32(&mut bytes, 0x04, (-17_i32) as u32);
+ put_u32(&mut bytes, 0x08, 513);
+ put_u32(&mut bytes, 0x0c, 0x7fc1_2345);
+ put_u32(&mut bytes, 0x10, (-3_i32) as u32);
+ bytes.extend_from_slice(&indexed_texm(2, 1, &[0xA5, 0x5A]));
+
+ let document = decode_tfnt(Arc::from(bytes.into_boxed_slice())).expect("Tfnt");
+
+ assert_eq!(document.header.fixed_width_floor, -17);
+ assert_eq!(document.header.vertical_extent, 513);
+ assert_eq!(document.header.word_0c_bits, 0x7fc1_2345);
+ assert_eq!(document.header.character_spacing, -3);
+ }
+
+ #[test]
+ fn preserves_distinct_glyph_records_including_byte_255_and_signed_advances() {
+ let mut bytes = tfnt_prefix();
+ write_glyph(
+ &mut bytes,
+ 7,
+ [0x7fc1_2345, 0x89ab_cdef, 0x0102_0304, (-23_i32) as u32],
+ );
+ write_glyph(
+ &mut bytes,
+ 255,
+ [0x8000_0000, 0x7654_3210, 0xffff_ffff, i32::MIN as u32],
+ );
+ bytes.extend_from_slice(&indexed_texm(2, 1, &[1, 2]));
+
+ let document = decode_tfnt(Arc::from(bytes.into_boxed_slice())).expect("Tfnt");
+
+ assert_eq!(
+ document.glyphs[7],
+ TfntGlyph {
+ word0: 0x7fc1_2345,
+ word1: 0x89ab_cdef,
+ word2: 0x0102_0304,
+ advance: -23,
+ }
+ );
+ assert_eq!(document.glyphs[255].word0, 0x8000_0000);
+ assert_eq!(document.glyphs[255].word1, 0x7654_3210);
+ assert_eq!(document.glyphs[255].word2, u32::MAX);
+ assert_eq!(document.glyphs[255].advance, i32::MIN);
+ }
+
+ #[test]
+ fn delegates_embedded_texture_to_texm_reader() {
+ let mut bytes = tfnt_prefix();
+ bytes.extend_from_slice(&indexed_texm(2, 1, &[0xA5, 0x5A]));
+
+ let document = decode_tfnt(Arc::from(bytes.into_boxed_slice())).expect("Tfnt");
+
+ assert_eq!(document.texm.width(), 2);
+ assert_eq!(document.texm.height(), 1);
+ assert_eq!(document.texm.mip_count(), 1);
+ assert_eq!(document.texm.format(), PixelFormat::Indexed8);
+ assert_eq!(document.texm.mip_level(0).expect("mip").bytes, [0xA5, 0x5A]);
+ }
+
+ #[test]
+ fn rejects_wrong_magic_and_short_prefix() {
+ let mut wrong_magic = tfnt_prefix();
+ wrong_magic[..4].copy_from_slice(b"BAD!");
+ assert!(matches!(
+ decode_tfnt(Arc::from(wrong_magic.into_boxed_slice())),
+ Err(TfntError::InvalidMagic { .. })
+ ));
+
+ let mut short_prefix = tfnt_prefix();
+ short_prefix.truncate(PREFIX_LEN - 1);
+ assert!(matches!(
+ decode_tfnt(Arc::from(short_prefix.into_boxed_slice())),
+ Err(TfntError::TruncatedHeader { .. })
+ ));
+ }
+
+ #[test]
+ fn rejects_short_fixed_glyph_table() {
+ let mut bytes = tfnt_prefix();
+ bytes.truncate(GLYPH_TABLE_END - 1);
+
+ assert!(matches!(
+ decode_tfnt(Arc::from(bytes.into_boxed_slice())),
+ Err(TfntError::TruncatedGlyphTable { .. })
+ ));
+ }
+
+ #[test]
+ fn reports_invalid_and_truncated_embedded_texm_without_panicking() {
+ let mut invalid = tfnt_prefix();
+ invalid.extend_from_slice(b"Not a Texm payload, but long enough to reach its reader.");
+ assert!(matches!(
+ decode_tfnt(Arc::from(invalid.into_boxed_slice())),
+ Err(TfntError::Texm(_))
+ ));
+
+ let mut truncated = tfnt_prefix();
+ truncated.extend_from_slice(b"Texm");
+ assert!(matches!(
+ decode_tfnt(Arc::from(truncated.into_boxed_slice())),
+ Err(TfntError::Texm(_))
+ ));
+ }
+
+ #[test]
+ #[ignore = "requires licensed GOG corpus"]
+ fn decodes_original_gog_tfnt_resources() {
+ let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| panic!("FPARKAN_CORPUS_PART1_ROOT is required for this test"));
+ assert!(
+ root.is_dir(),
+ "licensed corpus root is missing: {}",
+ root.display()
+ );
+
+ let font_bytes = std::fs::read(root.join("ui/font.lib")).expect("read ui/font.lib");
+ let fonts = decode_nres(
+ Arc::from(font_bytes.into_boxed_slice()),
+ ReadProfile::Strict,
+ )
+ .expect("decode ui/font.lib");
+ let expected = [
+ ("gf_1024.tft", 256, 256),
+ ("tf_1024.tft", 256, 256),
+ ("mf_1024.tft", 256, 256),
+ ("gf_800.tft", 128, 128),
+ ("tf_800.tft", 128, 128),
+ ("mf_800.tft", 256, 256),
+ ("gf_640.tft", 128, 128),
+ ("tf_640.tft", 128, 128),
+ ("mf_640.tft", 256, 256),
+ ];
+ assert_eq!(fonts.entry_count(), expected.len());
+ for (name, width, height) in expected {
+ let id = fonts
+ .find(name)
+ .unwrap_or_else(|| panic!("missing NRes font {name}"));
+ let entry = fonts.entry(id).expect("font entry");
+ assert_eq!(entry.meta().type_id, u32::from_le_bytes(*b"FONT"), "{name}");
+ let document = decode_tfnt(Arc::from(fonts.payload(id).expect("font payload")))
+ .unwrap_or_else(|err| panic!("{name}: {err}"));
+ assert_eq!(document.texm.width(), width, "{name}");
+ assert_eq!(document.texm.height(), height, "{name}");
+ assert_eq!(document.texm.format(), PixelFormat::Indexed8, "{name}");
+ assert_eq!(document.texm.mip_count(), 1, "{name}");
+ assert_eq!(
+ document.texm.mip_level(0).expect("mip").bytes.len(),
+ width as usize * height as usize,
+ "{name}"
+ );
+ if name == "gf_640.tft" {
+ assert_eq!(
+ document.header,
+ TfntHeader {
+ fixed_width_floor: 8,
+ vertical_extent: 7,
+ word_0c_bits: 0x3d80_0000,
+ character_spacing: 1,
+ }
+ );
+ assert_eq!(
+ document.glyphs[65],
+ TfntGlyph {
+ word0: 0x3c00_0000,
+ word1: 0x3d80_0000,
+ word2: 0x3e80_0000,
+ advance: 6,
+ }
+ );
+ assert_eq!(document.glyphs[255].advance, 5);
+ }
+ }
+
+ let sys_bytes = std::fs::read(root.join("sys.lib")).expect("read sys.lib");
+ let sys = decode_nres(Arc::from(sys_bytes.into_boxed_slice()), ReadProfile::Strict)
+ .expect("decode sys.lib");
+ let id = sys.find("ARIALTEX.TFT").expect("ARIALTEX.TFT entry");
+ let entry = sys.entry(id).expect("ARIALTEX.TFT entry");
+ assert_eq!(entry.meta().type_id, u32::from_le_bytes(*b"FONT"));
+ let document = decode_tfnt(Arc::from(sys.payload(id).expect("ARIALTEX.TFT payload")))
+ .expect("decode ARIALTEX.TFT");
+ assert_eq!(document.texm.width(), 128);
+ assert_eq!(document.texm.height(), 128);
+ assert_eq!(document.texm.format(), PixelFormat::Indexed8);
+ assert_eq!(document.texm.mip_count(), 1);
+ assert_eq!(
+ document.texm.mip_level(0).expect("mip").bytes.len(),
+ 128 * 128
+ );
+ assert_eq!(
+ document.header,
+ TfntHeader {
+ fixed_width_floor: 8,
+ vertical_extent: 7,
+ word_0c_bits: 0x3d80_0000,
+ character_spacing: 1,
+ }
+ );
+ assert_eq!(document.glyphs[65].advance, 6);
+ assert_eq!(document.glyphs[255].advance, 4);
+ }
+
+ fn tfnt_prefix() -> Vec<u8> {
+ let mut bytes = vec![0; GLYPH_TABLE_END];
+ bytes[..4].copy_from_slice(b"Tfnt");
+ bytes
+ }
+
+ fn indexed_texm(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> {
+ let mut bytes = Vec::with_capacity(32 + 1024 + pixels.len());
+ for word in [0x6d78_6554, width, height, 1, 0x20, 0x0100_0000, 0, 0] {
+ bytes.extend_from_slice(&word.to_le_bytes());
+ }
+ bytes.extend_from_slice(&[0; 1024]);
+ bytes.extend_from_slice(pixels);
+ bytes
+ }
+
+ fn put_u32(bytes: &mut [u8], offset: usize, value: u32) {
+ bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn write_glyph(bytes: &mut [u8], index: usize, words: [u32; 4]) {
+ let offset = PREFIX_LEN + index * 16;
+ for (word_index, value) in words.into_iter().enumerate() {
+ put_u32(bytes, offset + word_index * 4, value);
+ }
+ }
+}
diff --git a/crates/fparkan-fx/src/ambient_op3.rs b/crates/fparkan-fx/src/ambient_op3.rs
index 5bd2881..1ec2973 100644
--- a/crates/fparkan-fx/src/ambient_op3.rs
+++ b/crates/fparkan-fx/src/ambient_op3.rs
@@ -471,6 +471,7 @@ fn native_op3_type1_matrix(
/// `view · axis` value to exactly ±1 before building the cross-product frame;
/// neither input vector is normalized. The native path normalizes only
/// `view × axis`, then computes `axis × U` without normalizing the result.
+/// The authored axis is the quad's X column (Effect.dll RVA 0x97F6..0x983C).
#[must_use]
fn native_op3_type1_basis(
view: [f32; 3],
@@ -501,9 +502,9 @@ fn native_op3_type1_basis(
}
let w = cross3(axis, u);
let result = [
- [u[0], w[0], axis[0], position[0]],
- [u[1], w[1], axis[1], position[1]],
- [u[2], w[2], axis[2], position[2]],
+ [axis[0], u[0], w[0], position[0]],
+ [axis[1], u[1], w[1], position[1]],
+ [axis[2], u[2], w[2], position[2]],
[0.0, 0.0, 0.0, 1.0],
];
result
@@ -837,19 +838,19 @@ mod tests {
// cross-product frame instead of being normalized before the gate.
let frame =
native_op3_type1_basis([2.0, 1.0, 0.0], [1.0, 0.0, 0.0], [3.0, 4.0, 5.0]).unwrap();
- assert_eq!(frame[0], [0.0, 0.0, 1.0, 3.0]);
- assert_eq!(frame[1], [0.0, 1.0, 0.0, 4.0]);
- assert_eq!(frame[2], [-1.0, 0.0, 0.0, 5.0]);
+ assert_eq!(frame[0], [1.0, 0.0, 0.0, 3.0]);
+ assert_eq!(frame[1], [0.0, 0.0, 1.0, 4.0]);
+ assert_eq!(frame[2], [0.0, -1.0, 0.0, 5.0]);
// The axis is not normalized: W and the axis column keep its scale.
let axis_scaled =
native_op3_type1_basis([2.0, 1.0, 0.0], [0.0, 0.0, 2.0], [0.0; 3]).unwrap();
let inv_sqrt_5 = 1.0 / 5.0_f32.sqrt();
- assert_eq!(axis_scaled[0][0], inv_sqrt_5);
- assert_eq!(axis_scaled[1][0], -2.0 * inv_sqrt_5);
- assert_eq!(axis_scaled[0][1], 4.0 * inv_sqrt_5);
- assert_eq!(axis_scaled[1][1], 2.0 * inv_sqrt_5);
- assert_eq!(axis_scaled[2][2], 2.0);
+ assert_eq!(axis_scaled[0][1], inv_sqrt_5);
+ assert_eq!(axis_scaled[1][1], -2.0 * inv_sqrt_5);
+ assert_eq!(axis_scaled[0][2], 4.0 * inv_sqrt_5);
+ assert_eq!(axis_scaled[1][2], 2.0 * inv_sqrt_5);
+ assert_eq!(axis_scaled[2][0], 2.0);
assert_eq!(
native_op3_type1_basis([f32::NAN, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0; 3]),
@@ -907,6 +908,10 @@ mod tests {
assert_eq!(frame.normalized_time, 0.5);
assert_eq!(frame.color, [1.0, 1.0, 1.0, 0.5]);
assert_eq!(frame.material_phase, 0.5);
+ let authored_axis_edge = std::array::from_fn::<_, 3, _>(|axis| {
+ frame.vertices[2][axis] - frame.vertices[1][axis]
+ });
+ assert_eq!(authored_axis_edge, [0.0, 0.0, 1.0]);
assert!(evaluate_op3_type1_command(
&command,
4,
@@ -919,6 +924,197 @@ mod tests {
}
#[test]
+ #[ignore = "requires the original GOG installation"]
+ fn original_deflector_quad_keeps_its_authored_axis_over_time_and_camera_motion() {
+ let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation");
+ let bytes = std::fs::read(std::path::Path::new(&root).join("effects.rlb"))
+ .expect("read effects.rlb");
+ let archive = fparkan_nres::decode(bytes.into(), fparkan_nres::ReadProfile::Compatible)
+ .expect("decode effects.rlb");
+ let entry = archive.find("deflector").expect("deflector FXID");
+ let document = crate::decode_fxid(
+ archive
+ .payload(entry)
+ .expect("FXID payload")
+ .to_vec()
+ .into(),
+ )
+ .expect("decode deflector");
+ let command = &document.commands()[0];
+ let parent = [
+ [12.0, 0.0, 0.0, 100.0],
+ [0.0, 3.0, 0.0, 200.0],
+ [0.0, 0.0, 3.0, 20.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ for elapsed in [0.0_f32, 0.125, 0.5, 1.0, 2.5] {
+ for camera in [
+ [160.0, 230.0, 20.0],
+ [160.0, 200.0, 50.0],
+ [160.0, 170.0, 5.0],
+ ] {
+ let frame = evaluate_op3_type1_command(
+ command,
+ 0,
+ &parent,
+ elapsed,
+ elapsed.min(1.0),
+ camera,
+ )
+ .expect("active deflector quad");
+ assert!(frame.active);
+ assert_eq!(frame.material.name.to_ascii_lowercase(), "defl_b");
+ let axis_edge = std::array::from_fn::<_, 3, _>(|axis| {
+ frame.vertices[2][axis] - frame.vertices[1][axis]
+ });
+ assert_eq!(axis_edge, [12.0, 0.0, 0.0]);
+ let center = std::array::from_fn::<_, 3, _>(|axis| {
+ (frame.vertices[0][axis] + frame.vertices[2][axis]) * 0.5
+ });
+ assert_eq!(center, [106.0, 200.0, 20.0]);
+ assert_eq!(frame.material_phase, (elapsed * 7.0).fract());
+ }
+ }
+ }
+
+ #[test]
+ #[ignore = "requires the original GOG installation"]
+ fn original_tree_ray_58_matches_native_type1_geometry_samples() {
+ let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation");
+ let bytes = std::fs::read(std::path::Path::new(&root).join("effects.rlb"))
+ .expect("read effects.rlb");
+ let archive = fparkan_nres::decode(bytes.into(), fparkan_nres::ReadProfile::Compatible)
+ .expect("decode effects.rlb");
+ let entry = archive.find("tree_ray_58").expect("tree_ray_58 FXID");
+ let document = crate::decode_fxid(
+ archive
+ .payload(entry)
+ .expect("FXID payload")
+ .to_vec()
+ .into(),
+ )
+ .expect("decode tree_ray_58");
+ assert_eq!(document.header().time_mode, 2);
+ assert_eq!(document.header().duration_seconds, 3.0);
+ assert_eq!(document.commands().len(), 3);
+
+ let parent = [
+ [0.999_924_123, 0.012_319_660, 0.0, 386.673_400_879],
+ [-0.012_319_660, 0.999_924_123, 0.0, 603.692_321_777],
+ [0.0, 0.0, 1.0, 180.414_703_369],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ let center = [390.273_132, 603.647_949, 180.414_703];
+ // Original GOG Effect.dll RVA 0x93D0, x87 CW 0x007F; Rust-sampled keys are supplied separately.
+ let native_samples = [
+ (
+ 0,
+ "defl_y_add",
+ [0.0, 35.0, 22.0],
+ [
+ [386.280, 604.229_431, 179.568_024],
+ [386.266_876, 603.165_039, 181.261_383],
+ [394.266_266, 603.066_467, 181.261_383],
+ [394.279_4, 604.130_859, 179.568_024],
+ ],
+ ),
+ (
+ 0,
+ "defl_y_add",
+ [20.0, -30.0, 15.0],
+ [
+ [386.279, 604.147_4, 181.307_663],
+ [386.267_883, 603.247_1, 179.521_744],
+ [394.267_273, 603.148_5, 179.521_744],
+ [394.278_381, 604.048_8, 181.307_663],
+ ],
+ ),
+ (
+ 1,
+ "defl_y1_add",
+ [0.0, 35.0, 22.0],
+ [
+ [386.276_7, 603.963_3, 179.991_364],
+ [386.270_172, 603.431_152, 180.838_043],
+ [394.269_562, 603.332_6, 180.838_043],
+ [394.276_1, 603.864_746, 179.991_364],
+ ],
+ ),
+ ];
+ for (command_index, material_name, camera_offset, expected_vertices) in native_samples {
+ let camera = std::array::from_fn(|axis| center[axis] + camera_offset[axis]);
+ let frame = evaluate_op3_type1_command(
+ &document.commands()[command_index],
+ command_index,
+ &parent,
+ 0.0,
+ 0.0,
+ camera,
+ )
+ .expect("active tree ray quad");
+ assert_eq!(frame.material.name, material_name);
+ for (actual, expected) in frame
+ .vertices
+ .iter()
+ .flatten()
+ .zip(expected_vertices.iter().flatten())
+ {
+ assert!(
+ (actual - expected).abs() < 0.001,
+ "native sample vertex mismatch: {actual} vs {expected}"
+ );
+ }
+ }
+
+ for (command_index, material_name) in [(0, "defl_y_add"), (1, "defl_y1_add")] {
+ let command = &document.commands()[command_index];
+ assert_eq!(command.opcode, FxOpcode::Op3);
+ assert_eq!(
+ read_u32(&command.raw_body, 0),
+ Some(TYPE1_BODY_DISCRIMINATOR)
+ );
+ let camera_offsets = [[0.0, 35.0, 22.0], [20.0, -30.0, 15.0], [-25.0, 18.0, 10.0]];
+ for elapsed in [0.0_f32, 0.375, 0.75, 1.5, 2.25, 2.999, 3.125] {
+ let phase = elapsed.rem_euclid(3.0) / 3.0;
+ for camera_offset in camera_offsets {
+ let camera = std::array::from_fn(|axis| center[axis] + camera_offset[axis]);
+ let frame = evaluate_op3_type1_command(
+ command,
+ command_index,
+ &parent,
+ elapsed,
+ phase,
+ camera,
+ )
+ .expect("active tree ray quad");
+ assert_eq!(frame.material.name, material_name);
+ assert!(frame.active);
+ let actual_center: [f32; 3] = std::array::from_fn(|axis| {
+ frame
+ .vertices
+ .iter()
+ .map(|vertex| vertex[axis])
+ .sum::<f32>()
+ * 0.25
+ });
+ for axis in 0..3 {
+ assert!((actual_center[axis] - center[axis]).abs() < 0.001);
+ }
+ let authored_axis_edge: [f32; 3] = std::array::from_fn(|axis| {
+ frame.vertices[2][axis] - frame.vertices[1][axis]
+ });
+ let native_axis_edge = [7.999_393, -0.098_557_28, 0.0];
+ for axis in 0..3 {
+ assert!((authored_axis_edge[axis] - native_axis_edge[axis]).abs() < 0.001);
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
fn rejects_truncated_bodies_and_non_finite_tracks_or_inputs() {
let identity = [
[1.0, 0.0, 0.0, 0.0],
diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs
index 4b561e7..f9d7555 100644
--- a/crates/fparkan-msh/src/lib.rs
+++ b/crates/fparkan-msh/src/lib.rs
@@ -21,8 +21,13 @@ pub const STREAM_UV0: u32 = 5;
/// Optional secondary texture coordinate stream used by native lightmap/detail
/// stages. MSH stores it as the type-18 four-byte packed UV stream.
pub const STREAM_UV1: u32 = 18;
+/// Optional type-16 stream. Its presence marks MSH vertex-colour data.
+pub const STREAM_TYPE_16: u32 = 16;
/// Triangle index stream.
pub const STREAM_INDICES: u32 = 6;
+/// Optional triangle-descriptor stream.
+pub const STREAM_TRIANGLE_DESCRIPTORS: u32 = 7;
+const TRIANGLE_NORMAL_SCALE: f32 = 1.0 / 32_767.0;
/// Animation key stream.
pub const STREAM_ANIMATION_KEYS: u32 = 8;
/// Node names stream.
@@ -98,10 +103,16 @@ pub struct ModelAsset {
pub uv0: Option<Vec<[u16; 2]>>,
/// Optional secondary packed texture coordinates from type 18.
pub uv1: Option<Vec<[u16; 2]>>,
+ /// Whether the source MSH contains a type-16 vertex-colour stream.
+ ///
+ /// The stream payload is preserved separately and is not decoded here.
+ pub type16_present: bool,
/// Triangle indices.
pub indices: Vec<u16>,
/// Draw batches.
pub batches: Vec<Batch>,
+ /// Optional type-7 triangle descriptors.
+ pub triangle_descriptors: Option<Vec<TriangleDescriptor>>,
/// Optional decoded node names.
pub node_names: Option<Vec<Option<String>>>,
/// Optional decoded node-animation streams.
@@ -189,7 +200,13 @@ pub struct Batch {
pub material_index: u8,
/// Lightmap selector at byte offset `+0x05`.
pub lightmap_index: u8,
- /// Local batch index at byte offset `+0x06`.
+ /// Raw word at byte offset `+0x06`.
+ ///
+ /// In the native building-collision helper retry, this word together with
+ /// the model's base index selects the next linked Node38 root. For a
+ /// standalone `ModelAsset`, that base is zero, so the link is this raw
+ /// value. Keep the decoded field lossless; its meaning depends on the
+ /// query path and batch flags.
pub local_batch_index: u16,
/// Index count.
pub index_count: u16,
@@ -201,9 +218,48 @@ pub struct Batch {
pub base_vertex: u32,
}
-/// Preserved triangle descriptor stream marker.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct TriangleDescriptor;
+/// One 16-byte type-7 triangle descriptor.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct TriangleDescriptor {
+ /// Triangle mask flags at byte offset `+0`.
+ pub tri_flags: u16,
+ /// Opaque 16-bit words from offsets `+2`, `+4`, `+6` and `+14`.
+ pub opaque: [u16; 4],
+ /// Signed packed plane normal at byte offsets `+8`, `+10` and `+12`.
+ pub normal: [i16; 3],
+}
+
+/// Include-all and exclude-any masks used by the native MSH query.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct SegmentQueryMasks {
+ /// Node38 header bits that must all be set.
+ pub node_include: u16,
+ /// Node38 header bits that must all be clear.
+ pub node_exclude: u16,
+ /// Batch20 leading-DWORD bits that must all be set.
+ pub batch_include: u32,
+ /// Batch20 leading-DWORD bits that must all be clear.
+ pub batch_exclude: u32,
+ /// Type-7 triangle bits that must all be set.
+ pub triangle_include: u16,
+ /// Type-7 triangle bits that must all be clear.
+ pub triangle_exclude: u16,
+}
+
+/// One accepted segment hit from the native MSH triangle-query path.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SegmentHit {
+ /// Hit position in the same coordinate frame as the input segment.
+ pub position: [f32; 3],
+ /// Squared distance from the input segment's first endpoint.
+ pub distance_squared: f32,
+ /// Global Batch20 index.
+ pub batch_index: usize,
+ /// Global type-7 descriptor index.
+ pub triangle_descriptor_index: usize,
+ /// Global Res3 position indices of the hit triangle.
+ pub vertex_indices: [usize; 3],
+}
/// Vertex stream view.
#[derive(Clone, Debug, PartialEq)]
@@ -685,6 +741,287 @@ pub fn draw_batches(model: &ModelAsset, slot: SlotId) -> Result<&[Batch], MshErr
})
}
+/// Queries a segment against one slot using the native Node38, Batch20 and
+/// type-7 include-all/exclude-any masks.
+///
+/// The first endpoint is the native forward direction. Batch bit `0x0002`
+/// retries the plane test in reverse only when the forward plane test misses.
+/// The returned distance is always measured from the original first endpoint.
+///
+/// # Errors
+///
+/// Returns [`MshError`] when type 7 is absent, the model does not use Node38,
+/// or the selected slot's descriptor, batch, index or vertex ranges are invalid.
+pub fn query_slot_segment(
+ model: &ModelAsset,
+ node: NodeId,
+ slot: SlotId,
+ endpoints: [[f32; 3]; 2],
+ masks: SegmentQueryMasks,
+) -> Result<Option<SegmentHit>, MshError> {
+ let descriptors = model
+ .triangle_descriptors
+ .as_deref()
+ .ok_or(MshError::MissingStream {
+ type_id: STREAM_TRIANGLE_DESCRIPTORS,
+ label: "Res7",
+ })?;
+ if model.node_stride != 38 {
+ return Err(MshError::InvalidGeometry(format!(
+ "segment query requires Node38 metadata, got node stride {}",
+ model.node_stride
+ )));
+ }
+ if !endpoints.iter().flatten().all(|value| value.is_finite()) {
+ return Err(MshError::InvalidGeometry(
+ "segment endpoints must be finite".to_string(),
+ ));
+ }
+ let node_flags = node38_metadata(model, node)
+ .ok_or_else(|| MshError::InvalidGeometry("invalid Node38 metadata".to_string()))?
+ .header_flags;
+ let slot_index = usize::try_from(slot.0).map_err(|_| MshError::SlotOutOfBounds {
+ slot: slot.0,
+ slot_count: model.slots.len(),
+ })?;
+ let slot_ref = model
+ .slots
+ .get(slot_index)
+ .ok_or(MshError::SlotOutOfBounds {
+ slot: slot.0,
+ slot_count: model.slots.len(),
+ })?;
+ let batches = draw_batches(model, slot)?;
+ let descriptor_start = usize::from(slot_ref.tri_start);
+ let descriptor_end = descriptor_start
+ .checked_add(usize::from(slot_ref.tri_count))
+ .ok_or_else(|| MshError::InvalidGeometry("type-7 range overflow".to_string()))?;
+ if descriptor_end > descriptors.len() {
+ return Err(MshError::InvalidGeometry(format!(
+ "slot {slot_index} type-7 range {descriptor_start}..{descriptor_end} exceeds {} descriptors",
+ descriptors.len()
+ )));
+ }
+
+ let mut face_count = 0usize;
+ for (local_batch, batch) in batches.iter().enumerate() {
+ if batch.index_count % 3 != 0 {
+ return Err(MshError::InvalidGeometry(format!(
+ "batch {} index count {} is not triangle-aligned",
+ usize::from(slot_ref.batch_start) + local_batch,
+ batch.index_count
+ )));
+ }
+ let start = usize::try_from(batch.index_start)
+ .map_err(|_| MshError::InvalidGeometry("batch index range overflow".to_string()))?;
+ let end = start
+ .checked_add(usize::from(batch.index_count))
+ .ok_or_else(|| MshError::InvalidGeometry("batch index range overflow".to_string()))?;
+ if end > model.indices.len() {
+ return Err(MshError::InvalidGeometry(format!(
+ "batch {} index range {start}..{end} exceeds {} indices",
+ usize::from(slot_ref.batch_start) + local_batch,
+ model.indices.len()
+ )));
+ }
+ face_count = face_count
+ .checked_add(usize::from(batch.index_count) / 3)
+ .ok_or_else(|| MshError::InvalidGeometry("slot triangle count overflow".to_string()))?;
+ }
+ if face_count != usize::from(slot_ref.tri_count) {
+ return Err(MshError::InvalidGeometry(format!(
+ "slot {slot_index} declares {} type-7 descriptors but its batches contain {face_count} triangles",
+ slot_ref.tri_count
+ )));
+ }
+ if !mask_matches(
+ u32::from(node_flags),
+ u32::from(masks.node_include),
+ u32::from(masks.node_exclude),
+ ) {
+ return Ok(None);
+ }
+
+ let mut best = None;
+ let mut descriptor_cursor = descriptor_start;
+ for (local_batch, batch) in batches.iter().enumerate() {
+ let batch_index = usize::from(slot_ref.batch_start) + local_batch;
+ let batch_flags = u32::from(batch.batch_flags) | (u32::from(batch.material_index_hi) << 16);
+ let batch_face_count = usize::from(batch.index_count) / 3;
+ if mask_matches(batch_flags, masks.batch_include, masks.batch_exclude) {
+ for local_face in 0..batch_face_count {
+ let triangle_descriptor_index = descriptor_cursor + local_face;
+ let descriptor = descriptors[triangle_descriptor_index];
+ if !mask_matches(
+ u32::from(descriptor.tri_flags),
+ u32::from(masks.triangle_include),
+ u32::from(masks.triangle_exclude),
+ ) {
+ continue;
+ }
+ let index_start = usize::try_from(batch.index_start)
+ .expect("validated batch index range")
+ + local_face * 3;
+ let base_vertex = usize::try_from(batch.base_vertex)
+ .map_err(|_| MshError::InvalidGeometry("base vertex overflow".to_string()))?;
+ let mut vertex_indices = [0usize; 3];
+ for (triangle_corner, index) in vertex_indices.iter_mut().enumerate() {
+ let raw_index = usize::from(model.indices[index_start + triangle_corner]);
+ *index = base_vertex.checked_add(raw_index).ok_or_else(|| {
+ MshError::InvalidGeometry("resolved vertex index overflow".to_string())
+ })?;
+ if *index >= model.positions.len() {
+ return Err(MshError::VertexIndexOutOfBounds {
+ batch: batch_index,
+ vertex: u64::try_from(*index).unwrap_or(u64::MAX),
+ position_count: model.positions.len(),
+ });
+ }
+ }
+ let triangle = vertex_indices.map(|index| model.positions[index]);
+ if !triangle.iter().flatten().all(|value| value.is_finite()) {
+ return Err(MshError::InvalidGeometry(format!(
+ "triangle descriptor {triangle_descriptor_index} has a non-finite position"
+ )));
+ }
+ let normal = descriptor
+ .normal
+ .map(|value| f32::from(value) * TRIANGLE_NORMAL_SCALE);
+ if normal == [0.0; 3] {
+ continue;
+ }
+ let forward =
+ native_segment_plane_hit(normal, triangle[0], endpoints[0], endpoints[1])?;
+ let position = match forward {
+ Some(position) => Some(position),
+ None if batch.batch_flags & 0x0002 != 0 => {
+ native_segment_plane_hit(normal, triangle[0], endpoints[1], endpoints[0])?
+ }
+ None => None,
+ };
+ let Some(position) = position else {
+ continue;
+ };
+ if !native_point_in_triangle(normal, triangle, position)? {
+ continue;
+ }
+ let offset = [
+ position[0] - endpoints[0][0],
+ position[1] - endpoints[0][1],
+ position[2] - endpoints[0][2],
+ ];
+ let distance_squared = dot3(offset, offset);
+ if !distance_squared.is_finite() {
+ return Err(MshError::InvalidGeometry(
+ "segment hit distance is non-finite".to_string(),
+ ));
+ }
+ if best.is_none_or(|hit: SegmentHit| distance_squared <= hit.distance_squared) {
+ best = Some(SegmentHit {
+ position,
+ distance_squared,
+ batch_index,
+ triangle_descriptor_index,
+ vertex_indices,
+ });
+ }
+ }
+ }
+ descriptor_cursor += batch_face_count;
+ }
+ Ok(best)
+}
+
+fn mask_matches(flags: u32, include: u32, exclude: u32) -> bool {
+ flags & include == include && flags & exclude == 0
+}
+
+fn native_segment_plane_hit(
+ normal: [f32; 3],
+ anchor: [f32; 3],
+ start: [f32; 3],
+ end: [f32; 3],
+) -> Result<Option<[f32; 3]>, MshError> {
+ let direction = [end[0] - start[0], end[1] - start[1], end[2] - start[2]];
+ let denominator = dot3(normal, direction);
+ let start_distance = dot3(normal, start) - dot3(normal, anchor);
+ let end_distance = start_distance + denominator;
+ if !denominator.is_finite() || !start_distance.is_finite() || !end_distance.is_finite() {
+ return Err(MshError::InvalidGeometry(
+ "segment plane calculation is non-finite".to_string(),
+ ));
+ }
+ if denominator >= 0.0 || start_distance <= 0.0 || end_distance > 0.0 {
+ return Ok(None);
+ }
+ let ratio = start_distance / denominator;
+ let position = [
+ start[0] - direction[0] * ratio,
+ start[1] - direction[1] * ratio,
+ start[2] - direction[2] * ratio,
+ ];
+ if !position.iter().all(|value| value.is_finite()) {
+ return Err(MshError::InvalidGeometry(
+ "segment intersection is non-finite".to_string(),
+ ));
+ }
+ Ok(Some(position))
+}
+
+fn native_point_in_triangle(
+ normal: [f32; 3],
+ triangle: [[f32; 3]; 3],
+ point: [f32; 3],
+) -> Result<bool, MshError> {
+ let absolute = normal.map(f32::abs);
+ let xy_max = if absolute[0] < absolute[1] {
+ absolute[1]
+ } else {
+ absolute[0]
+ };
+ let dominant_axis = if absolute[2] <= xy_max {
+ if absolute[0] < absolute[1] {
+ 1
+ } else {
+ 0
+ }
+ } else {
+ 2
+ };
+ for index in 0..3 {
+ let previous = triangle[(index + 2) % 3];
+ let current = triangle[index];
+ let relative = [
+ point[0] - previous[0],
+ point[1] - previous[1],
+ point[2] - previous[2],
+ ];
+ let edge = [
+ current[0] - previous[0],
+ current[1] - previous[1],
+ current[2] - previous[2],
+ ];
+ let side = match dominant_axis {
+ 0 => (relative[2] * edge[1] + relative[1] * -edge[2]) * normal[0],
+ 1 => (-edge[0] * relative[2] + relative[0] * edge[2]) * normal[1],
+ _ => (relative[1] * edge[0] - relative[0] * edge[1]) * normal[2],
+ };
+ if !side.is_finite() {
+ return Err(MshError::InvalidGeometry(
+ "triangle edge calculation is non-finite".to_string(),
+ ));
+ }
+ if side < 0.0 {
+ return Ok(false);
+ }
+ }
+ Ok(true)
+}
+
+fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 {
+ left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
+}
+
impl MshDocument {
/// Returns original stream descriptors.
#[must_use]
@@ -791,6 +1128,10 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
let uv1 = read_optional_stream(document, STREAM_UV1)?
.map(|raw| parse_secondary_uv_stream(raw, positions.len()))
.transpose()?;
+ let type16_present = read_optional_stream(document, STREAM_TYPE_16)?.is_some();
+ let triangle_descriptors = read_optional_triangle_descriptors(document)?
+ .map(|raw| parse_triangle_descriptors(&raw.bytes))
+ .transpose()?;
let node_names = read_optional_stream(document, STREAM_NAMES)?
.map(|raw| parse_res10_names(&raw.bytes, node_count))
.transpose()?;
@@ -807,8 +1148,10 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
normals,
uv0,
uv1,
+ type16_present,
indices,
batches,
+ triangle_descriptors,
node_names,
animation,
})
@@ -879,6 +1222,28 @@ fn read_optional_stream(
}))
}
+fn read_optional_triangle_descriptors(
+ document: &NresDocument,
+) -> Result<Option<RawStream>, MshError> {
+ let mut entries = document
+ .entries()
+ .iter()
+ .filter(|entry| entry.meta().type_id == STREAM_TRIANGLE_DESCRIPTORS);
+ let Some(entry) = entries.next() else {
+ return Ok(None);
+ };
+ if entries.next().is_some() {
+ return Err(MshError::DuplicateStream {
+ type_id: STREAM_TRIANGLE_DESCRIPTORS,
+ label: "Res7",
+ });
+ }
+ Ok(Some(RawStream {
+ attributes: attributes(entry.meta()),
+ bytes: document.payload(entry.id())?.to_vec(),
+ }))
+}
+
fn parse_slots(data: &[u8]) -> Result<Vec<Slot>, MshError> {
if data.len() < 0x8C {
return Err(MshError::InvalidGeometry(format!(
@@ -997,6 +1362,30 @@ fn parse_batches(data: &[u8]) -> Result<Vec<Batch>, MshError> {
Ok(out)
}
+fn parse_triangle_descriptors(data: &[u8]) -> Result<Vec<TriangleDescriptor>, MshError> {
+ if !data.len().is_multiple_of(16) {
+ return Err(invalid_resource_size("Res7", data.len(), 16));
+ }
+ let mut out = Vec::with_capacity(data.len() / 16);
+ for offset in (0..data.len()).step_by(16) {
+ out.push(TriangleDescriptor {
+ tri_flags: read_u16_required(data, offset)?,
+ opaque: [
+ read_u16_required(data, offset + 2)?,
+ read_u16_required(data, offset + 4)?,
+ read_u16_required(data, offset + 6)?,
+ read_u16_required(data, offset + 14)?,
+ ],
+ normal: [
+ read_i16_required(data, offset + 8)?,
+ read_i16_required(data, offset + 10)?,
+ read_i16_required(data, offset + 12)?,
+ ],
+ });
+ }
+ Ok(out)
+}
+
fn parse_u16_array(data: &[u8], label: &'static str) -> Result<Vec<u16>, MshError> {
if !data.len().is_multiple_of(2) {
return Err(invalid_resource_size(label, data.len(), 2));
@@ -1212,6 +1601,12 @@ fn read_u16_required(bytes: &[u8], offset: usize) -> Result<u16, MshError> {
Ok(u16::from_le_bytes(arr))
}
+fn read_i16_required(bytes: &[u8], offset: usize) -> Result<i16, MshError> {
+ Ok(i16::from_le_bytes(
+ read_u16_required(bytes, offset)?.to_le_bytes(),
+ ))
+}
+
fn read_i8(bytes: &[u8], offset: usize) -> Result<i8, MshError> {
let byte = bytes
.get(offset)
@@ -1259,6 +1654,371 @@ mod tests {
assert_eq!(model.node_stride, 38);
assert_eq!(model.node_count, 0);
assert!(model.slots.is_empty());
+ assert_eq!(model.triangle_descriptors, None);
+ assert!(!model.type16_present);
+ }
+
+ #[test]
+ fn type16_presence_is_preserved_without_decoding_its_payload() {
+ let payload = [0xDE, 0xAD, 0xBE, 0xEF];
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream(STREAM_TYPE_16, 0, b"Res16", &payload),
+ ]))
+ .expect("nested NRes");
+ let model =
+ validate_msh(&decode_msh(&document).expect("MSH document")).expect("model asset");
+
+ assert!(model.type16_present);
+ }
+
+ #[test]
+ fn type7_decodes_optional_records_and_keeps_opaque_words() {
+ let mut raw = Vec::new();
+ push_u16(&mut raw, 0x3456);
+ push_u16(&mut raw, 0x1111);
+ push_u16(&mut raw, 0x2222);
+ push_u16(&mut raw, 0x3333);
+ raw.extend_from_slice(&(-1_i16).to_le_bytes());
+ raw.extend_from_slice(&i16::MIN.to_le_bytes());
+ raw.extend_from_slice(&i16::MAX.to_le_bytes());
+ push_u16(&mut raw, 0x4444);
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7", &raw),
+ ]))
+ .expect("nested NRes");
+ let msh = decode_msh(&document).expect("optional type7 stream");
+ let model = validate_msh(&msh).expect("model");
+
+ assert_eq!(
+ model.triangle_descriptors,
+ Some(vec![TriangleDescriptor {
+ tri_flags: 0x3456,
+ opaque: [0x1111, 0x2222, 0x3333, 0x4444],
+ normal: [-1, i16::MIN, i16::MAX],
+ }])
+ );
+ let preserved = msh.preserved_streams().expect("preserved streams");
+ assert_eq!(
+ preserved
+ .iter()
+ .find(|stream| stream.type_id == STREAM_TRIANGLE_DESCRIPTORS)
+ .unwrap()
+ .bytes
+ .as_ref(),
+ raw
+ );
+ }
+
+ #[test]
+ fn malformed_or_duplicate_optional_type7_stream_is_an_error() {
+ for entries in [
+ vec![
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7", &[0; 15]),
+ ],
+ vec![
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7a", &[0; 16]),
+ stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7b", &[0; 16]),
+ ],
+ ] {
+ let document = decode_nested(&build_nres(&entries)).expect("nested NRes");
+ let msh = decode_msh(&document).expect("required streams");
+ assert!(validate_msh(&msh).is_err());
+ }
+ }
+
+ #[test]
+ fn segment_query_uses_native_one_sided_edge_and_endpoint_rules() {
+ let model = segment_model(&[(0, 0)], &[0]);
+ let hit = query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("forward segment")
+ .expect("front-to-back hit");
+ assert_eq!(hit.position, [0.0, 0.0, 0.0]);
+ assert_eq!(hit.distance_squared, 1.0);
+ assert_eq!(hit.vertex_indices, [0, 1, 2]);
+
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, -1.0, 0.0], [0.0, 1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("reverse segment")
+ .is_none());
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, 0.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("endpoint on plane")
+ .is_some());
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 0.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("start on plane")
+ .is_none());
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, -1.0], [0.0, -1.0, -1.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("segment crossing triangle edge")
+ .is_some());
+
+ let two_sided = segment_model(&[(2, 0)], &[0]);
+ assert!(query_slot_segment(
+ &two_sided,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, -1.0, 0.0], [0.0, 1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("two-sided reverse retry")
+ .is_some());
+ }
+
+ #[test]
+ fn segment_query_skips_triangles_with_zero_packed_normals() {
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model
+ .triangle_descriptors
+ .as_mut()
+ .expect("type-7 descriptors")[0]
+ .normal = [0; 3];
+
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("zero normal is a missed triangle")
+ .is_none());
+ }
+
+ #[test]
+ fn segment_query_applies_native_masks_and_advances_filtered_descriptors() {
+ let mut model = segment_model(&[(0x0200, 1), (0, 1), (0, 1)], &[0, 0x0020, 0x0004]);
+ model
+ .triangle_descriptors
+ .as_mut()
+ .expect("type-7 descriptors")
+ .insert(
+ 0,
+ TriangleDescriptor {
+ tri_flags: 0,
+ opaque: [0; 4],
+ normal: [0, i16::MAX, 0],
+ },
+ );
+ model.slots[0].tri_start = 1;
+ let masks = SegmentQueryMasks {
+ node_include: 0x0004,
+ node_exclude: 0,
+ batch_include: 0x0001_0000,
+ batch_exclude: 0x0000_0200,
+ triangle_include: 0x0004,
+ triangle_exclude: 0x0020,
+ };
+ let hit = query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ masks,
+ )
+ .expect("native masks")
+ .expect("third descriptor survives");
+ assert_eq!(hit.batch_index, 2);
+ assert_eq!(hit.triangle_descriptor_index, 3);
+ assert_eq!(hit.vertex_indices, [6, 7, 8]);
+
+ for masks in [
+ SegmentQueryMasks {
+ node_include: 0x0008,
+ ..masks
+ },
+ SegmentQueryMasks {
+ node_exclude: 0x0004,
+ ..masks
+ },
+ SegmentQueryMasks {
+ batch_include: 0x0002_0000,
+ ..masks
+ },
+ SegmentQueryMasks {
+ triangle_exclude: 0x0004,
+ ..masks
+ },
+ ] {
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ masks,
+ )
+ .expect("filtered query")
+ .is_none());
+ }
+ }
+
+ #[test]
+ fn segment_query_replaces_equal_distance_hits_with_the_later_candidate() {
+ let model = segment_model(&[(0, 0), (0, 0)], &[0, 0]);
+ let hit = query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("equal-distance candidates")
+ .expect("hit");
+ assert_eq!(hit.distance_squared, 1.0);
+ assert_eq!(hit.batch_index, 1);
+ assert_eq!(hit.triangle_descriptor_index, 1);
+ }
+
+ #[test]
+ fn native_point_in_triangle_projects_on_each_dominant_normal_axis() {
+ for (normal, triangle, edge, outside) in [
+ (
+ [1.0, 0.0, 0.0],
+ [[0.0, -1.0, -1.0], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]],
+ [0.0, 0.0, -1.0],
+ [0.0, 2.0, 0.0],
+ ),
+ (
+ [0.0, 1.0, 0.0],
+ [[-1.0, 0.0, -1.0], [0.0, 0.0, 1.0], [1.0, 0.0, -1.0]],
+ [0.0, 0.0, -1.0],
+ [0.0, 0.0, 2.0],
+ ),
+ (
+ [0.0, 0.0, 1.0],
+ [[-1.0, -1.0, 0.0], [1.0, -1.0, 0.0], [0.0, 1.0, 0.0]],
+ [0.0, -1.0, 0.0],
+ [2.0, 0.0, 0.0],
+ ),
+ ] {
+ assert!(native_point_in_triangle(normal, triangle, [0.0; 3]).expect("inside"));
+ assert!(native_point_in_triangle(normal, triangle, edge).expect("edge"));
+ assert!(!native_point_in_triangle(normal, triangle, outside).expect("outside"));
+ }
+ }
+
+ #[test]
+ fn segment_query_reports_missing_type7_and_invalid_metadata_or_ranges() {
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model.triangle_descriptors = None;
+ assert!(matches!(
+ query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ ),
+ Err(MshError::MissingStream {
+ type_id: STREAM_TRIANGLE_DESCRIPTORS,
+ ..
+ })
+ ));
+
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model.node_stride = 24;
+ assert!(matches!(
+ query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ ),
+ Err(MshError::InvalidGeometry(_))
+ ));
+
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model.slots[0].tri_start = 1;
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .is_err());
+
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model.slots[0].batch_count = 2;
+ assert!(matches!(
+ query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ ),
+ Err(MshError::BatchRangeOutOfBounds { .. })
+ ));
+
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model.batches[0].index_count = 4;
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .is_err());
+
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model.batches[0].index_start = 3;
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .is_err());
}
#[test]
@@ -1535,8 +2295,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
+ triangle_descriptors: None,
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
@@ -1596,8 +2358,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
+ triangle_descriptors: None,
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
@@ -1660,8 +2424,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
+ triangle_descriptors: None,
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
@@ -2420,6 +3186,156 @@ mod tests {
track.sample(AnimationTime(frame as f32))
}
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn factory_wall_door_and_reversed_segments_match_native_query() {
+ let root = corpus_root("IS");
+ let archive_path = files_under(&root)
+ .into_iter()
+ .find(|path| {
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("fortif.rlb"))
+ })
+ .expect("fortif.rlb in licensed corpus");
+ let archive_bytes = std::fs::read(&archive_path).expect("fortif.rlb bytes");
+ let archive = fparkan_nres::decode(
+ Arc::from(archive_bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .expect("fortif.rlb archive");
+ let entry = archive
+ .entries()
+ .iter()
+ .find(|entry| entry.name_bytes().eq_ignore_ascii_case(b"fr_l_plant.msh"))
+ .expect("plant factory MSH");
+ let nested = fparkan_nres::decode(
+ Arc::from(
+ archive
+ .payload(entry.id())
+ .expect("MSH payload")
+ .to_vec()
+ .into_boxed_slice(),
+ ),
+ ReadProfile::Compatible,
+ )
+ .expect("nested plant MSH");
+ let msh = decode_msh(&nested).expect("plant MSH document");
+ let model = validate_msh(&msh).expect("plant MSH model");
+ let slot = selected_slot(&model, NodeId(1), Lod(0), Group(0)).expect("Node 1 slot");
+ assert_eq!(slot, SlotId(0));
+ let hierarchy = node38_fallback_hierarchy(&model).expect("Node38 poses");
+ let pose = hierarchy.poses[1];
+ assert_eq!(pose.rotation, [0.0, 0.0, 0.0, 1.0]);
+
+ let masks = SegmentQueryMasks {
+ node_include: 0,
+ node_exclude: 0,
+ batch_include: 0,
+ batch_exclude: 0x0208,
+ triangle_include: 0,
+ triangle_exclude: 0x0024,
+ };
+ let local = |point: [f32; 3]| {
+ [
+ point[0] - pose.translation[0],
+ point[1] - pose.translation[1],
+ point[2] - pose.translation[2],
+ ]
+ };
+ let wall = query_slot_segment(
+ &model,
+ NodeId(1),
+ slot,
+ [local([-10.5, 22.16, 2.5]), local([-10.5, 19.16, 2.5])],
+ masks,
+ )
+ .expect("wall query")
+ .expect("solid wall hit");
+ assert_eq!(wall.batch_index, 5);
+ assert_eq!(wall.triangle_descriptor_index, 62);
+ assert_eq!(wall.distance_squared, 4.0);
+ assert!(query_slot_segment(
+ &model,
+ NodeId(1),
+ slot,
+ [local([0.5, 22.16, 2.5]), local([0.5, 19.16, 2.5])],
+ masks,
+ )
+ .expect("door query")
+ .is_none());
+ assert!(query_slot_segment(
+ &model,
+ NodeId(1),
+ slot,
+ [local([-10.5, 19.16, 2.5]), local([-10.5, 22.16, 2.5])],
+ masks,
+ )
+ .expect("reversed wall query")
+ .is_none());
+ }
+
+ fn segment_model(batch_fields: &[(u16, u16)], tri_flags: &[u16]) -> ModelAsset {
+ assert_eq!(batch_fields.len(), tri_flags.len());
+ let mut nodes_raw = node38([u16::MAX; 15]);
+ nodes_raw[0..2].copy_from_slice(&0x0004_u16.to_le_bytes());
+ let mut positions = Vec::with_capacity(batch_fields.len() * 3);
+ let mut indices = Vec::with_capacity(batch_fields.len() * 3);
+ let mut batches = Vec::with_capacity(batch_fields.len());
+ for index in 0..batch_fields.len() {
+ positions.extend_from_slice(&[[-1.0, 0.0, -1.0], [0.0, 0.0, 1.0], [1.0, 0.0, -1.0]]);
+ indices.extend_from_slice(&[0, 1, 2]);
+ batches.push(Batch {
+ batch_flags: batch_fields[index].0,
+ material_index_hi: batch_fields[index].1,
+ material_index: 0,
+ lightmap_index: 0,
+ local_batch_index: u16::try_from(index).expect("local batch index"),
+ index_count: 3,
+ index_start: u32::try_from(index * 3).expect("index start"),
+ vertex_count: 3,
+ base_vertex: u32::try_from(index * 3).expect("base vertex"),
+ });
+ }
+ ModelAsset {
+ node_stride: 38,
+ node_count: 1,
+ nodes_raw,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 1.0,
+ slots: vec![Slot {
+ tri_start: 0,
+ tri_count: u16::try_from(tri_flags.len()).expect("triangle count"),
+ batch_start: 0,
+ batch_count: u16::try_from(batch_fields.len()).expect("batch count"),
+ aabb_min: [-1.0, 0.0, -1.0],
+ aabb_max: [1.0, 0.0, 1.0],
+ sphere_center: [0.0; 3],
+ sphere_radius: 1.0,
+ opaque: [0; 5],
+ }],
+ positions,
+ normals: None,
+ uv0: None,
+ uv1: None,
+ type16_present: false,
+ indices,
+ batches,
+ node_names: None,
+ animation: None,
+ triangle_descriptors: Some(
+ tri_flags
+ .iter()
+ .map(|tri_flags| TriangleDescriptor {
+ tri_flags: *tri_flags,
+ opaque: [0; 4],
+ normal: [0, i16::MAX, 0],
+ })
+ .collect(),
+ ),
+ }
+ }
+
fn decode_nested(bytes: &[u8]) -> Result<NresDocument, NresError> {
fparkan_nres::decode(
Arc::from(bytes.to_vec().into_boxed_slice()),
diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs
index 08a285d..ad96204 100644
--- a/crates/fparkan-render/src/lib.rs
+++ b/crates/fparkan-render/src/lib.rs
@@ -361,6 +361,64 @@ pub enum LegacyCullMode {
FrontFace,
}
+/// CPU diffuse alpha for a colourless MSH batch carrying flag `0x0008`.
+///
+/// `packet_distance` is the distance to the packet's first accepted vertex,
+/// after its object transform. Flag `0x0010` takes precedence over `0x0040`.
+/// Returns `None` for invalid distance or projection inputs. Callers must keep
+/// the material alpha for batches with a type-16 colour stream or without `0x0008`.
+#[must_use]
+pub fn legacy_msh_portal_alpha(
+ batch_flags: u16,
+ packet_distance: f32,
+ horizontal_fov: f32,
+) -> Option<f32> {
+ if !packet_distance.is_finite()
+ || packet_distance < 0.0
+ || !horizontal_fov.is_finite()
+ || horizontal_fov <= 0.0
+ {
+ return None;
+ }
+ let (distance, near, far, slope) = if batch_flags & 0x0010 != 0 {
+ (
+ packet_distance.sqrt(),
+ 2.6 / horizontal_fov,
+ 7.799_999_7 / horizontal_fov,
+ (1.0 / (7.799_999_7 - 2.6)) * horizontal_fov,
+ )
+ } else if batch_flags & 0x0040 != 0 {
+ return Some(0.0);
+ } else {
+ let near = 75.0 * 1.3;
+ let far = 95.0 * 1.3;
+ (
+ packet_distance,
+ horizontal_fov.recip() * near,
+ horizontal_fov.recip() * far,
+ (1.0 / (far - near)) * horizontal_fov,
+ )
+ };
+ Some(if distance < near {
+ 0.0
+ } else if distance > far {
+ 1.0
+ } else {
+ ((distance - near) * slope).clamp(0.0, 1.0)
+ })
+}
+
+/// Raster depth adjustment applied to one pipeline variant.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum LegacyDepthBias {
+ /// Preserve the interpolated depth exactly.
+ #[default]
+ Disabled,
+ /// Move projected decals toward the camera so they pass against their
+ /// coplanar receiver under a less-or-equal depth test.
+ ProjectedDecal,
+}
+
/// Legacy fixed-function state that changes graphics-pipeline structure.
///
/// Alpha reference is deliberately not present: it is dynamic material data,
@@ -375,6 +433,8 @@ pub struct LegacyPipelineState {
pub cull: LegacyCullMode,
/// Whether alpha-test shader logic is enabled.
pub alpha_test: bool,
+ /// Raster depth bias for projected decals.
+ pub depth_bias: LegacyDepthBias,
}
impl LegacyPipelineState {
@@ -405,15 +465,16 @@ impl LegacyPipelineState {
/// Canonical, backend-neutral key for a graphics-pipeline variant.
///
/// The value is explicitly packed rather than hashed, so captures and caches
-/// remain stable across processes and Rust toolchain updates. Three bits are
-/// reserved for the five native material blend modes.
+/// remain stable across processes and Rust toolchain updates. Bits 0..7 encode
+/// blend, depth, cull and alpha-test state; bit 8 distinguishes projected
+/// decal depth bias.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct PipelineKey(u8);
+pub struct PipelineKey(u16);
impl PipelineKey {
/// Returns the canonical packed representation.
#[must_use]
- pub const fn packed(self) -> u8 {
+ pub const fn packed(self) -> u16 {
self.0
}
}
@@ -437,7 +498,11 @@ impl From<LegacyPipelineState> for PipelineKey {
LegacyCullMode::BackFace => 1,
LegacyCullMode::FrontFace => 2,
};
- Self(blend | (depth << 3) | (cull << 5) | (u8::from(state.alpha_test) << 7))
+ let depth_bias = match state.depth_bias {
+ LegacyDepthBias::Disabled => 0,
+ LegacyDepthBias::ProjectedDecal => 1 << 8,
+ };
+ Self(blend | (depth << 3) | (cull << 5) | (u16::from(state.alpha_test) << 7) | depth_bias)
}
}
@@ -445,6 +510,40 @@ impl From<LegacyPipelineState> for PipelineKey {
mod tests {
use super::*;
+ #[test]
+ fn portal_alpha_matches_native_packet_distance_vectors() {
+ // GOG Terrain+1B0F0 with its real +246A0/+247B0 packet methods,
+ // Ngi32 RSQRTSS dispatch, x87 CW=007F, FOV=1.04.
+ for (distance, flags, expected) in [
+ (0.499_938_96, 0x18, 0.0),
+ (4.999_542, 0x18, 0.0),
+ (9.999_084, 0x18, 0.132_426_6),
+ (25.000_572, 0x18, 0.500_011_44),
+ (59.999_084, 0x18, 1.0),
+ (85.012_48, 0x08, 0.0),
+ (109.994_125, 0x08, 0.649_765_2),
+ (25.000_572, 0x58, 0.500_011_44),
+ (110.0, 0x48, 0.0),
+ ] {
+ let actual = legacy_msh_portal_alpha(flags, distance, 1.04).unwrap();
+ assert!(
+ (actual - expected).abs() < 0.000_002,
+ "{distance}: {actual}"
+ );
+ }
+ assert_eq!(legacy_msh_portal_alpha(8, 60.0, 1.3), Some(0.0));
+ assert!((legacy_msh_portal_alpha(8, 85.0, 1.3).unwrap() - 0.5).abs() < 0.000_001);
+ assert_eq!(legacy_msh_portal_alpha(8, 110.0, 1.3), Some(1.0));
+ for (distance, fov) in [
+ (f32::NAN, 1.3),
+ (-1.0, 1.3),
+ (1.0, 0.0),
+ (1.0, f32::INFINITY),
+ ] {
+ assert_eq!(legacy_msh_portal_alpha(8, distance, fov), None);
+ }
+ }
+
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
let mut result = [0.0; 16];
for row in 0..4 {
@@ -614,6 +713,24 @@ mod tests {
}
#[test]
+ fn projected_decal_bias_is_a_distinct_preserved_pipeline_state() {
+ let ordinary = LegacyPipelineState::default();
+ let decal = LegacyPipelineState {
+ depth_bias: LegacyDepthBias::ProjectedDecal,
+ ..ordinary
+ };
+ assert_eq!(ordinary.depth_bias, LegacyDepthBias::Disabled);
+ assert_ne!(PipelineKey::from(ordinary), PipelineKey::from(decal));
+ assert_eq!(
+ decal
+ .with_material_category(1)
+ .expect("native category is represented")
+ .depth_bias,
+ LegacyDepthBias::ProjectedDecal
+ );
+ }
+
+ #[test]
fn native_material_categories_have_distinct_pipeline_keys() {
let base = LegacyPipelineState {
depth: LegacyDepthMode::TestWrite,
diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml
index f66551b..80e5b78 100644
--- a/crates/fparkan-runtime/Cargo.toml
+++ b/crates/fparkan-runtime/Cargo.toml
@@ -7,6 +7,7 @@ repository.workspace = true
[dependencies]
fparkan-assets = { path = "../fparkan-assets" }
+fparkan-fx = { path = "../fparkan-fx" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-path = { path = "../fparkan-path" }
fparkan-platform = { path = "../fparkan-platform" }
@@ -17,3 +18,6 @@ fparkan-resource = { path = "../fparkan-resource" }
fparkan-script = { path = "../fparkan-script" }
fparkan-vfs = { path = "../fparkan-vfs" }
fparkan-world = { path = "../fparkan-world" }
+
+[dev-dependencies]
+fparkan-animation = { path = "../fparkan-animation" }
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 5e352fc..5cc2d86 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -1,29 +1,33 @@
#![forbid(unsafe_code)]
//! Runtime orchestration for mission loading and world stepping.
-#[cfg(test)]
-use fparkan_assets::PreparedControlResourcePair;
+use fparkan_assets::ClanBody;
use fparkan_assets::{
decode_mission_land_path, decode_mission_payload, decode_nres_payload,
derive_mission_land_paths, extend_graph_report_with_visual_dependencies,
mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError,
AssetId, AssetManager, BehaviorProfileRates, BehaviorProfileVarError, BuildCategory,
MissionAssetPlan, MissionDocument, MissionError, MissionTerrainPaths, NresError,
- PreparedComponentControl, PreparedControlEffectSection, PreparedControlNodeDataRow,
- PreparedControlNodeDataTable, PreparedControlTypedRecord, PreparedVisual, TerrainFormatError,
- TerrainPreparationError, TerrainWorld, TmaProfile,
+ PreparedComponentControl, PreparedControlEffectRow, PreparedControlEffectSection,
+ PreparedControlNodeDataRow, PreparedControlNodeDataTable, PreparedControlResourcePair,
+ PreparedControlTypedRecord, PreparedVisual, ResearchError, ResearchTrfDocument,
+ TerrainFormatError, TerrainPreparationError, TerrainWorld, TmaProfile,
};
+use fparkan_fx::{decode_fxid, FxOpcode, FXID_KIND};
use fparkan_msh::{node38_metadata, selected_slot, Group, Lod, NodeId};
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
use fparkan_prototype::{
- build_prototype_graph_report, unit_component_tree, PrototypeGraph, PrototypeGraphFailure,
- PrototypeGraphReport, UnitComponentRecord, UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO,
- UNIT_DAT_BAS_PLACEMENT_FLAG,
+ build_prototype_graph_report, resolve_prototype_all, unit_component_tree, PrototypeGraph,
+ PrototypeGraphFailure, PrototypeGraphReport, PrototypeSource, UnitComponentRecord,
+ UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO, UNIT_DAT_BAS_PLACEMENT_FLAG,
+};
+use fparkan_resource::{
+ resource_name, CachedResourceRepository, ResourceError, ResourceKey, ResourceRepository,
};
-use fparkan_resource::{resource_name, CachedResourceRepository, ResourceKey, ResourceRepository};
use fparkan_script::{
- Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
- ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand,
+ FormulaTable, Handler19InitInput, ScriptInitResolution, ScriptInitResolveError,
+ ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSet, VarSetDefault,
+ VmHostCallbackCommand,
};
use fparkan_terrain::SurfaceQuery;
use fparkan_vfs::{Vfs, VfsError};
@@ -36,9 +40,13 @@ use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::sync::Arc;
+mod mesh_collision;
+
pub use fparkan_assets::MissionAssets;
+pub use mesh_collision::{query_building_segment_world, BuildingMeshHit, BuildingMeshNode};
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256;
+const MAX_MISSION_AI_RECORD_SLOTS: usize = 1_000_000;
const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1;
const UNIT_DAT_CONTROL_CLASS_HIGH_BIT: u32 = 0x8000_0000;
@@ -82,6 +90,15 @@ pub struct MissionRequest {
pub mode: MissionMode,
}
+/// Explicit inputs supplied by the startup caller to the full mission loader.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct MissionStartupInput {
+ /// Caller-supplied queue clock used for the initial Behavior deadline.
+ pub queue_time_ms: u32,
+ /// Whether this startup creates the per-clan research tables.
+ pub research_creation_gate: bool,
+}
+
const INITIAL_SCRIPT_HOST_STATUS: u32 = 5;
/// Ordered mission property preserved for later runtime stages.
@@ -190,6 +207,251 @@ pub struct ControlDeviceRecordProvenance {
pub source: ResourceKey,
}
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+struct Factory2StringPair {
+ archive: String,
+ name: String,
+}
+
+impl Factory2StringPair {
+ fn empty() -> Self {
+ Self {
+ archive: String::new(),
+ name: String::new(),
+ }
+ }
+
+ fn matches_ascii_case(&self, other: &Self) -> bool {
+ self.archive.eq_ignore_ascii_case(&other.archive)
+ && self.name.eq_ignore_ascii_case(&other.name)
+ }
+
+ fn cache_key(&self) -> Self {
+ Self {
+ archive: self.archive.to_ascii_lowercase(),
+ name: self.name.to_ascii_lowercase(),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum Factory2PairOutcome {
+ Value(u32),
+ NativeMiss,
+ Unrepresentable,
+}
+
+fn resolve_factory2_pair_from_assets<R: ResourceRepository>(
+ asset_manager: &AssetManager<R>,
+ vfs: &dyn Vfs,
+ pair: &Factory2StringPair,
+) -> Result<Factory2PairOutcome, AssetPreparationError> {
+ if !pair.archive.eq_ignore_ascii_case("objects.rlb")
+ || pair.name.is_empty()
+ || pair
+ .name
+ .bytes()
+ .any(|byte| matches!(byte, b'/' | b'\\' | b'.'))
+ || normalize_relative(pair.name.as_bytes(), PathPolicy::StrictLegacy).is_err()
+ {
+ return Ok(Factory2PairOutcome::Unrepresentable);
+ }
+
+ let root_name = resource_name(pair.name.as_bytes());
+ let prototypes =
+ resolve_prototype_all(asset_manager.repository(), vfs, &root_name).map_err(|source| {
+ AssetPreparationError::InvalidPrototype(format!(
+ "factory-2 pair {}:{}: {source}",
+ pair.archive, pair.name
+ ))
+ })?;
+ let [prototype] = prototypes.as_slice() else {
+ return Ok(Factory2PairOutcome::Unrepresentable);
+ };
+ if prototype.source != PrototypeSource::ObjectsRegistry
+ || prototype.source_type != Some(CONTROL_CLASS_SOURCE_BULL)
+ || !matches!(
+ prototype.geometry,
+ fparkan_prototype::PrototypeGeometry::Mesh(_)
+ )
+ {
+ return Ok(Factory2PairOutcome::Unrepresentable);
+ }
+
+ let assets = asset_manager.prepare_mission_assets(&[0..1], &prototypes)?;
+ let Some(controls) = assets.object_component_controls.first() else {
+ return Ok(Factory2PairOutcome::Unrepresentable);
+ };
+ let [Some(control)] = controls.as_slice() else {
+ return Ok(Factory2PairOutcome::Unrepresentable);
+ };
+ if !control.timeline_rows.is_empty()
+ || !control.typed_records.iter().all(|record| record.tag == 17)
+ || !matches!(
+ &control.effect_section,
+ PreparedControlEffectSection::Absent | PreparedControlEffectSection::Parsed(_)
+ )
+ {
+ return Ok(Factory2PairOutcome::Unrepresentable);
+ }
+ let Some(node_data) = control.node_data.as_ref() else {
+ return Ok(Factory2PairOutcome::Unrepresentable);
+ };
+ let Some(opcodes) = asset_manager.initial_control_effect_opcodes(
+ &control.source,
+ control.model_node_count,
+ 0,
+ )?
+ else {
+ return Ok(Factory2PairOutcome::Unrepresentable);
+ };
+ if !opcodes.iter().all(|opcode| matches!(opcode, 1 | 4 | 12)) {
+ return Ok(Factory2PairOutcome::Unrepresentable);
+ }
+ if !factory2_selected_fx_resources_are_representable(asset_manager, control, &opcodes)? {
+ return Ok(Factory2PairOutcome::Unrepresentable);
+ }
+ match factory2_node_data_maximum_bits(&node_data.rows) {
+ Ok(bits) => Ok(Factory2PairOutcome::Value(bits)),
+ Err(_) => Ok(Factory2PairOutcome::Unrepresentable),
+ }
+}
+
+fn factory2_selected_fx_resources_are_representable<R: ResourceRepository>(
+ asset_manager: &AssetManager<R>,
+ control: &PreparedComponentControl,
+ selected_opcodes: &[u32],
+) -> Result<bool, AssetPreparationError> {
+ let selected_opcode4_count = selected_opcodes
+ .iter()
+ .filter(|opcode| **opcode == 4)
+ .count();
+ let PreparedControlEffectSection::Parsed(rows) = &control.effect_section else {
+ return Ok(selected_opcode4_count == 0);
+ };
+ let selected_rows = rows
+ .iter()
+ .filter(|row| Some(row.table_index) == control.initial_effect_group)
+ .collect::<Vec<_>>();
+ if selected_rows.len() != selected_opcode4_count {
+ return Ok(false);
+ }
+ if selected_rows.is_empty() {
+ return Ok(true);
+ }
+ let Some(points) = control.point_list.as_ref() else {
+ return Ok(false);
+ };
+
+ for row in selected_rows {
+ if !row.point_offsets.iter().all(|offset| {
+ *offset != u32::MAX
+ && usize::try_from(*offset).is_ok_and(|index| index < points.points.len())
+ }) {
+ return Ok(false);
+ }
+ let Some(key) = factory2_fx_resource_key(&row.resource) else {
+ return Ok(false);
+ };
+ if !factory2_fx_resource_is_representable(asset_manager.repository(), &key, row)? {
+ return Ok(false);
+ }
+ }
+ Ok(true)
+}
+
+fn factory2_fx_resource_key(resource: &PreparedControlResourcePair) -> Option<ResourceKey> {
+ let archive = bounded_ascii_c_string(&resource.archive)?;
+ let name = bounded_ascii_c_string(&resource.name)?;
+ if archive.is_empty() || name.is_empty() {
+ return None;
+ }
+ Some(ResourceKey {
+ archive: normalize_relative(archive.as_bytes(), PathPolicy::StrictLegacy).ok()?,
+ name: resource_name(name.as_bytes()),
+ type_id: Some(FXID_KIND),
+ })
+}
+
+fn factory2_fx_resource_is_representable<R: ResourceRepository>(
+ repository: &R,
+ key: &ResourceKey,
+ row: &PreparedControlEffectRow,
+) -> Result<bool, AssetPreparationError> {
+ let context = format!(
+ "factory-2 selected Control effect group {} row {} FX {}:{}",
+ row.table_index,
+ row.row_index,
+ key.archive.as_str(),
+ String::from_utf8_lossy(&key.name.0)
+ );
+ let archive = match repository.open_archive_unchanged(&key.archive) {
+ Ok(archive) => archive,
+ Err(ResourceError::MissingArchive { .. } | ResourceError::MissingEntry) => {
+ return Ok(false);
+ }
+ Err(source) => {
+ return Err(AssetPreparationError::Resource {
+ context,
+ source: Box::new(source),
+ });
+ }
+ };
+ let entry =
+ repository
+ .find(archive, &key.name)
+ .map_err(|source| AssetPreparationError::Resource {
+ context: context.clone(),
+ source: Box::new(source),
+ })?;
+ let Some(entry) = entry else {
+ return Ok(false);
+ };
+ let info = repository
+ .entry_info(entry)
+ .map_err(|source| AssetPreparationError::Resource {
+ context: context.clone(),
+ source: Box::new(source),
+ })?;
+ if info.key.type_id != Some(FXID_KIND) {
+ return Ok(false);
+ }
+ let bytes = repository
+ .read(entry)
+ .map_err(|source| AssetPreparationError::Resource {
+ context: context.clone(),
+ source: Box::new(source),
+ })?;
+ let document = decode_fxid(Arc::from(bytes.into_owned())).map_err(|source| {
+ AssetPreparationError::InvalidPrototype(format!("{context}: {source}"))
+ })?;
+ Ok(document.commands().iter().all(|command| {
+ matches!(
+ command.opcode,
+ FxOpcode::Op2
+ | FxOpcode::Op3
+ | FxOpcode::Op4
+ | FxOpcode::Op5
+ | FxOpcode::Op7
+ | FxOpcode::Op8
+ | FxOpcode::Op9
+ )
+ }))
+}
+
+fn factory2_node_data_maximum_bits(
+ rows: &[PreparedControlNodeDataRow],
+) -> Result<u32, &'static str> {
+ let mut maximum = 0.0_f32;
+ for row in rows {
+ let value = checked_control_float(2, "factory-2 NDP Max", row.max_volume)
+ .map_err(|_| "NDP Max float")?;
+ maximum = checked_control_sum(2, "factory-2 NDP Max sum", maximum, value)
+ .map_err(|_| "NDP Max sum")?;
+ }
+ Ok(maximum.to_bits())
+}
+
/// Common fields known for one row in a host object's Control device table.
///
/// The row index is its position in the containing `Vec`. This stores the
@@ -210,6 +472,8 @@ pub struct EffectiveControlDevice {
pub class9_selector0_scale_bits: Option<u32>,
/// Factory-26 mutable resource-rate addition at native `+0xa4`.
pub tag26_extra_rate_bits: Option<u32>,
+ /// Factory-2 value read by selector 6 at native `+0x174`.
+ pub device174_bits: Option<u32>,
/// Six class-9 budget values at native `+0x98..+0xac`.
pub class9_values_bits: Option<[u32; 6]>,
/// Six accumulated class-9 changes at native `+0xb0..+0xc4`.
@@ -481,6 +745,38 @@ pub struct MissionControlDeviceTableState {
pub current_output_rate_5e0_bits: u32,
}
+/// One initial outer row prepared from the current Control descriptor table.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionBehaviorControlGroup {
+ /// Table ordinal of the descriptor whose current tag starts this group.
+ pub header_device_ordinal: usize,
+ /// Table ordinals of current-tag-2 descriptors following this header.
+ pub candidate_device_ordinals: Vec<usize>,
+ /// Table ordinals of current-tag-30 descriptors following this header.
+ pub tag30_device_ordinals: Vec<usize>,
+}
+
+/// Initial grouping of a prepared Control table, before live Behavior refreshes.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct MissionBehaviorControlGrouping {
+ /// Groups in the order their current-tag-1 headers appeared.
+ pub groups: Vec<MissionBehaviorControlGroup>,
+ /// Current-tag-2 entries before the first header.
+ pub ungrouped_candidate_device_ordinals: Vec<usize>,
+}
+
+/// Initial Behavior grouping projection for one full-load mission object.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionBehaviorControlGroupingState {
+ /// Stable identity from the source TMA object order.
+ pub original_id: Option<OriginalObjectId>,
+ /// Corresponding live Rust world object.
+ pub handle: ObjectHandle,
+ /// Initial grouping, or the reason its prepared Control table is unavailable.
+ pub grouping:
+ Result<MissionBehaviorControlGrouping, MissionControlDeviceTableUnavailableReason>,
+}
+
/// Provenance for the class passed to Control's constructor.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MissionControlClassSource {
@@ -654,9 +950,11 @@ pub struct MissionScriptBundle {
pub base_path: String,
/// Resolved compiled `.scr` path.
pub script_path: String,
- /// Losslessly decoded compiled package; only proven `Handler(19)` Init
- /// writes are resolved and retained by runtime.
+ /// Losslessly decoded compiled package; only the recovered first-`Init`
+ /// subset is resolved and retained by runtime.
pub package: ScriptPackage,
+ /// Optional formula rows loaded from this bundle's sibling `.fml` file.
+ pub formula_table: Option<FormulaTable>,
/// Outcome of the restricted first-`Init` resolution for this bundle.
pub init_execution: MissionScriptInitExecution,
/// Recovered per-AI Behavior scheduler and total state.
@@ -664,6 +962,26 @@ pub struct MissionScriptBundle {
pub behavior_warm_start: Option<BehaviorWarmStartState>,
}
+/// Initial native record-slot words owned by one mission clan's AI instance.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionAiStartupState {
+ /// TMA clan index that owns this AI instance.
+ pub clan_index: usize,
+ /// Sentinel words appended during AI startup, in native insertion order.
+ pub record_slots: Vec<u32>,
+}
+
+/// One decoded research table associated with its TMA clan owner and source path.
+#[derive(Debug)]
+pub struct MissionResearchTrfTable {
+ /// TMA clan index that owns this table.
+ pub clan_index: usize,
+ /// Resolved TMA second-resource path used to load this table.
+ pub path: String,
+ /// Independently decoded TRF document.
+ pub document: ResearchTrfDocument,
+}
+
/// Behavior distributor clock and totals tracked from warm start through resource ticks.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BehaviorWarmStartState {
@@ -722,11 +1040,12 @@ fn schedule_behavior_distributor_tick(
Some(behavior_elapsed_seconds(elapsed_ms))
}
-/// Numeric script defaults selected for the mission's first script bundle.
+/// Numeric script declarations selected for the mission's first script bundle.
///
/// GOG `ai.dll` first tries `<bundle-base>.var`, then falls back to the shared
/// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source
-/// and supplies the typed targets for the proven `Handler(19)` Init writes.
+/// and supplies typed targets for recovered `Handler(19)` and numeric formula
+/// assignments in the first `Init` event.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptVarSet {
/// Resolved `.var` path selected by the original fallback order.
@@ -737,11 +1056,7 @@ pub struct MissionScriptVarSet {
pub declarations: VarSet,
}
-/// One resolved, corpus-proven `Init` result for a mission clan.
-///
-/// This is intentionally limited to `Handler(19)`: an `Init` event's other
-/// selectors are retained in [`MissionScriptBundle::package`] but are not
-/// guessed as executable behavior.
+/// One resolved subset of the first `Init` event for a mission clan.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptInitState {
/// TMA clan index that owns the `SuperAI` instance and varset.
@@ -750,8 +1065,8 @@ pub struct MissionScriptInitState {
pub event_index: usize,
/// Raw event word preserved from the compiled `Init` event.
pub event_word: u32,
- /// Exact three DWORD writes made by each proven `Handler(19)` instruction.
- pub writes: Vec<Handler19DwordWrite>,
+ /// Ordered numeric writes made by the supported `Init` instructions.
+ pub writes: Vec<ScriptInitWrite>,
}
/// Execution status of the first event whose case-sensitive C-string name is `Init`.
@@ -765,14 +1080,14 @@ pub enum MissionScriptInitExecution {
event_index: usize,
},
/// A preview retained the decoded package but stopped before the first
- /// instruction outside the recovered `Handler(19)` action subset.
+ /// instruction outside the recovered numeric `Init` subset.
Unsupported {
/// Zero-based event table index selected by native lookup.
event_index: usize,
/// Zero-based instruction index where restricted resolution stopped.
instruction_index: usize,
- /// Typed selector, control, or result-save shape that is not modeled.
- error: Handler19ResolveError,
+ /// Typed selector, operand, or expression shape that is not modeled.
+ error: ScriptInitResolveError,
},
}
@@ -823,6 +1138,7 @@ struct MissionLoadOptions {
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct LoadedMissionScripts {
bundles: Vec<MissionScriptBundle>,
+ ai_startup_states: Vec<MissionAiStartupState>,
varset: Option<MissionScriptVarSet>,
init_states: Vec<MissionScriptInitState>,
varset_states: Vec<MissionScriptVarSetState>,
@@ -915,7 +1231,7 @@ pub struct LoadedMission {
pub script_event_count: usize,
/// Numeric script defaults loaded through the original bundle/fallback rule.
pub script_varset_declaration_count: usize,
- /// `Handler(19)` Init results resolved for mission clans.
+ /// Writes resolved for the first mission-clan `Init` events.
pub script_init_state_count: usize,
/// Instantiated per-clan varsets whose Init writes have been applied.
pub script_varset_state_count: usize,
@@ -950,6 +1266,9 @@ struct LoadedMissionState {
behavior_ore_states: Vec<BehaviorOreState>,
behavior_profiles: Vec<BehaviorProfileState>,
control_device_tables: Vec<MissionControlDeviceTableState>,
+ behavior_control_groupings: Vec<MissionBehaviorControlGroupingState>,
+ research_trf_tables: Vec<MissionResearchTrfTable>,
+ ai_startup_states: Vec<MissionAiStartupState>,
script_bundles: Vec<MissionScriptBundle>,
script_varset: Option<MissionScriptVarSet>,
script_init_states: Vec<MissionScriptInitState>,
@@ -994,7 +1313,7 @@ pub enum EngineError {
/// Parse diagnostic.
message: String,
},
- /// A recovered `Handler(19)` Init input or target was not representable.
+ /// A recovered `Init` input or write target was not representable.
ScriptInit {
/// Owning TMA clan index.
clan_index: usize,
@@ -1012,7 +1331,7 @@ pub enum EngineError {
/// Zero-based instruction index within that event.
instruction_index: usize,
/// Typed unsupported instruction shape.
- source: Handler19ResolveError,
+ source: ScriptInitResolveError,
},
/// A recovered script handler could not resolve against runtime state.
ScriptRuntime {
@@ -1021,6 +1340,17 @@ pub enum EngineError {
/// Handler-specific diagnostic.
message: String,
},
+ /// A clan's initial AI record slots exceeded the Rust allocation budget or could not be reserved.
+ MissionAiRecordSlots {
+ /// Owning TMA clan index.
+ clan_index: usize,
+ /// Raw second-resource tag interpreted as unsigned bits.
+ raw_count: u32,
+ /// Slots already reserved for earlier clans in this mission.
+ allocated_slots: usize,
+ /// Budget or allocation diagnostic.
+ message: String,
+ },
/// `NRes` decode error.
Nres {
/// Resource path.
@@ -1028,6 +1358,22 @@ pub enum EngineError {
/// Source error.
source: NresError,
},
+ /// A loaded clan's second TMA resource could not be decoded as a TRF table.
+ ResearchTrf {
+ /// Owning TMA clan index.
+ clan_index: usize,
+ /// Resolved resource path.
+ path: String,
+ /// TRF decode diagnostic.
+ source: ResearchError,
+ },
+ /// A clan's research path is not representable by the supported ASCII path layer.
+ ResearchTrfPath {
+ /// Owning TMA clan index.
+ clan_index: usize,
+ /// Raw C-string bytes before path normalization.
+ path: Vec<u8>,
+ },
/// Mission decode error.
Mission {
/// Resource path.
@@ -1184,7 +1530,25 @@ impl std::fmt::Display for EngineError {
clan_index,
message,
} => write!(f, "clan {clan_index}: script runtime failed: {message}"),
+ Self::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message,
+ } => write!(
+ f,
+ "clan {clan_index} AI initial slots (unsigned raw count {raw_count}, after {allocated_slots} mission slots): {message}"
+ ),
Self::Nres { path, source } => write!(f, "{path}: {source}"),
+ Self::ResearchTrf {
+ clan_index,
+ path,
+ source,
+ } => write!(f, "clan {clan_index} research TRF {path}: {source}"),
+ Self::ResearchTrfPath { clan_index, path } => write!(
+ f,
+ "clan {clan_index} research TRF path contains non-ASCII bytes: {path:?}"
+ ),
Self::Mission { path, source } => write!(f, "{path}: {source}"),
Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"),
Self::Terrain(source) => write!(f, "{source}"),
@@ -1223,6 +1587,7 @@ impl std::error::Error for EngineError {
Self::Path { source, .. } => Some(source),
Self::Vfs { source, .. } => Some(source),
Self::Nres { source, .. } => Some(source),
+ Self::ResearchTrf { source, .. } => Some(source),
Self::Mission { source, .. } => Some(source),
Self::TerrainFormat { source, .. } => Some(source),
Self::ScriptInitUnsupported { source, .. } => Some(source),
@@ -1234,7 +1599,9 @@ impl std::error::Error for EngineError {
| Self::VarSet { .. }
| Self::ScriptInit { .. }
| Self::ScriptRuntime { .. }
+ | Self::MissionAiRecordSlots { .. }
| Self::Movement(_)
+ | Self::ResearchTrfPath { .. }
| Self::PrototypeGraph { .. }
| Self::BehaviorProfile { .. }
| Self::BehaviorDistributorTick { .. }
@@ -1255,10 +1622,12 @@ pub fn create(services: EngineServices) -> Result<Engine, EngineError> {
/// Loads a mission transactionally and runs the recovered initial script subset.
///
-/// `queue_time_ms` is the caller-supplied 32-bit queue-clock sample used to
-/// initialize the first Behavior deadline for each script bundle. This runtime
-/// entry point does not acquire the native queue clock or reproduce the full
-/// SuperAI host lifecycle.
+/// `startup_input.queue_time_ms` is the caller-supplied 32-bit queue-clock
+/// sample used to initialize the first Behavior deadline for each script bundle.
+/// `startup_input.research_creation_gate` explicitly controls whether full
+/// startup loads per-clan research tables; it is not inferred from mission mode
+/// or path. This runtime entry point does not acquire the native queue clock or
+/// reproduce the full SuperAI host lifecycle.
///
/// # Errors
///
@@ -1270,13 +1639,13 @@ pub fn create(services: EngineServices) -> Result<Engine, EngineError> {
pub fn load_mission(
engine: &mut Engine,
request: MissionRequest,
- queue_time_ms: u32,
+ startup_input: MissionStartupInput,
) -> Result<LoadedMission, EngineError> {
load_mission_with_options(
engine,
request,
MissionLoadOptions::default(),
- Some(queue_time_ms),
+ Some(startup_input),
)
}
@@ -1335,7 +1704,7 @@ fn load_mission_with_options(
engine: &mut Engine,
request: MissionRequest,
options: MissionLoadOptions,
- queue_time_ms: Option<u32>,
+ startup_input: Option<MissionStartupInput>,
) -> Result<LoadedMission, EngineError> {
let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?;
let mission_path = normalize_engine_path("mission", &request.key)?;
@@ -1387,6 +1756,13 @@ fn load_mission_with_options(
}
})?;
let mut loaded_scripts = load_mission_scripts(&vfs, &mission, options.asset_scope)?;
+ let research_trf_tables = if options.asset_scope == MissionAssetScope::Full
+ && startup_input.is_some_and(|input| input.research_creation_gate)
+ {
+ load_mission_research_trf_tables(&vfs, &mission, &loaded_scripts.ai_startup_states)?
+ } else {
+ Vec::new()
+ };
let repository = CachedResourceRepository::with_payload_cache_budget(
vfs.clone(),
MISSION_DECODED_PAYLOAD_CACHE_ENTRIES,
@@ -1595,7 +1971,9 @@ fn load_mission_with_options(
Vec::new()
};
+ let mut first_factory2_asset_error = None;
let control_device_tables = if options.asset_scope == MissionAssetScope::Full {
+ let mut factory2_pair_cache = HashMap::new();
object_drafts
.iter()
.zip(&handles)
@@ -1609,6 +1987,29 @@ fn load_mission_with_options(
prototype_span,
&resolved_prototypes,
);
+ let mut resolve_factory2_pair = |pair: &Factory2StringPair| {
+ if first_factory2_asset_error.is_some() {
+ return Factory2PairOutcome::Unrepresentable;
+ }
+ let cache_key = pair.cache_key();
+ if let Some(outcome) = factory2_pair_cache.get(&cache_key) {
+ return *outcome;
+ }
+ match resolve_factory2_pair_from_assets(
+ &asset_manager,
+ vfs.as_ref(),
+ pair,
+ ) {
+ Ok(outcome) => {
+ factory2_pair_cache.insert(cache_key, outcome);
+ outcome
+ }
+ Err(error) => {
+ first_factory2_asset_error = Some(error);
+ Factory2PairOutcome::Unrepresentable
+ }
+ }
+ };
let projection = build_control_device_projection_with_class(
object_index,
draft,
@@ -1616,6 +2017,7 @@ fn load_mission_with_options(
prototype_span,
&resolved_prototypes,
native_control_class,
+ &mut resolve_factory2_pair,
);
match projection {
Ok(projection) => MissionControlDeviceTableState {
@@ -1659,10 +2061,27 @@ fn load_mission_with_options(
} else {
Vec::new()
};
+ if let Some(source) = first_factory2_asset_error {
+ let _ = fparkan_world::shutdown(new_runtime_world);
+ return Err(EngineError::AssetPreparation {
+ mission: request.key.clone(),
+ source,
+ });
+ }
+ let behavior_control_groupings = if options.asset_scope == MissionAssetScope::Full {
+ control_device_tables
+ .iter()
+ .map(project_mission_behavior_control_grouping)
+ .collect()
+ } else {
+ Vec::new()
+ };
let mut next_behavior_rng_state = engine.behavior_rng_state;
if options.asset_scope == MissionAssetScope::Full {
- let queue_time_ms = queue_time_ms.expect("full mission loading requires a queue time");
+ let queue_time_ms = startup_input
+ .expect("full mission loading requires startup inputs")
+ .queue_time_ms;
if let Err(error) = start_mission_scripts(
&mission,
&mut loaded_scripts,
@@ -1738,6 +2157,9 @@ fn load_mission_with_options(
behavior_ore_states,
behavior_profiles,
control_device_tables,
+ behavior_control_groupings,
+ research_trf_tables,
+ ai_startup_states: loaded_scripts.ai_startup_states,
script_bundles: loaded_scripts.bundles,
script_varset: loaded_scripts.varset,
script_init_states: loaded_scripts.init_states,
@@ -1819,6 +2241,24 @@ pub fn loaded_mission_script_bundles(engine: &Engine) -> Option<&[MissionScriptB
.map(|state| state.script_bundles.as_slice())
}
+/// Returns the per-clan AI record slots initialized during full mission startup.
+#[must_use]
+pub fn loaded_mission_ai_startup_states(engine: &Engine) -> Option<&[MissionAiStartupState]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.ai_startup_states.as_slice())
+}
+
+/// Returns the per-clan TRF tables loaded during full mission startup.
+#[must_use]
+pub fn loaded_mission_research_trf_tables(engine: &Engine) -> Option<&[MissionResearchTrfTable]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.research_trf_tables.as_slice())
+}
+
/// Returns the numeric script defaults selected by the original `.var` fallback rule.
#[must_use]
pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVarSet> {
@@ -1828,10 +2268,11 @@ pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVar
.and_then(|state| state.script_varset.as_ref())
}
-/// Returns the proven script `Init` writes resolved for the loaded mission.
+/// Returns the recovered script `Init` writes resolved for the loaded mission.
///
-/// The list has one entry for each clan whose `Init` event contains recovered
-/// `Handler(19)` instructions. It is not a general script event executor.
+/// The list has one entry for each clan whose first `Init` event contains a
+/// supported `Handler(19)` instruction or numeric FML literal assignment. It
+/// is not a general script event executor.
#[must_use]
pub fn loaded_mission_script_init_states(engine: &Engine) -> Option<&[MissionScriptInitState]> {
engine
@@ -2058,9 +2499,65 @@ pub fn loaded_mission_control_device_tables(
.map(|state| state.control_device_tables.as_slice())
}
-/// Queries native Control global-table selector 1 (weighted ratio) or 2 (sum).
+/// Returns the initial prepared Control grouping for full-load mission objects.
+#[must_use]
+pub fn loaded_mission_behavior_control_groupings(
+ engine: &Engine,
+) -> Option<&[MissionBehaviorControlGroupingState]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.behavior_control_groupings.as_slice())
+}
+
+fn project_mission_behavior_control_grouping(
+ table: &MissionControlDeviceTableState,
+) -> MissionBehaviorControlGroupingState {
+ let grouping = match &table.status {
+ MissionControlDeviceTableStatus::Available(devices) => {
+ Ok(build_mission_behavior_control_grouping(devices))
+ }
+ MissionControlDeviceTableStatus::Unavailable(reason) => Err(reason.clone()),
+ };
+ MissionBehaviorControlGroupingState {
+ original_id: table.original_id,
+ handle: table.handle,
+ grouping,
+ }
+}
+
+fn build_mission_behavior_control_grouping(
+ devices: &[EffectiveControlDevice],
+) -> MissionBehaviorControlGrouping {
+ let mut grouping = MissionBehaviorControlGrouping::default();
+ for (ordinal, device) in devices.iter().enumerate() {
+ match device.descriptor.tag {
+ 1 => {
+ grouping.groups.push(MissionBehaviorControlGroup {
+ header_device_ordinal: ordinal,
+ candidate_device_ordinals: Vec::new(),
+ tag30_device_ordinals: Vec::new(),
+ });
+ }
+ 2 => match grouping.groups.last_mut() {
+ Some(group) => group.candidate_device_ordinals.push(ordinal),
+ None => grouping.ungrouped_candidate_device_ordinals.push(ordinal),
+ },
+ 30 => {
+ if let Some(group) = grouping.groups.last_mut() {
+ group.tag30_device_ordinals.push(ordinal);
+ }
+ }
+ _ => {}
+ }
+ }
+ grouping
+}
+
+/// Queries native Control global-table selector 1 (weighted ratio), 2 (sum), or 15.
/// `Ok(None)` preserves the original false/no-result behavior for an empty or
-/// zero-denominator table. Other unsupported native paths are reported as errors.
+/// zero-denominator table or selector 15 without both cached factory rows. Other
+/// unsupported native paths are reported as errors.
/// Inputs and intermediates must be finite and normal, or exact zero.
///
/// # Errors
@@ -2084,20 +2581,27 @@ pub fn query_loaded_mission_control_resource(
query_control_device_table_resource(table, selector)
}
-/// Queries one loaded Control device through its original factory getter.
+/// Queries one loaded Control device through its indexed selector interface.
///
-/// Selector `0xe00` is modeled for current descriptor tags 5 and 26 with
-/// factory tags 5 and 26. Both common factory readers use the device key to
-/// read the current provider value and multiply it by coefficient 0. Only
-/// current tag 26 also multiplies by `M`; current tag 5 ignores `M` and
-/// descriptor word 8 for this selector. Explicit Control update calls can
-/// change current provider or multiplier values; the mission clock does not
-/// advance them automatically.
+/// Selector `0x400` returns the current type-1 value from the provider row
+/// selected by the device key, including signed zero, regardless of current
+/// descriptor or factory tag. Selector `0xe00` is modeled for current
+/// descriptor tags 5 and 26 with factory tags 5 and 26. Both common factory
+/// readers use the device key to read the current provider value and multiply
+/// it by coefficient 0. Only current tag 26 also multiplies by `M`; current
+/// tag 5 ignores `M` and descriptor word 8 for this selector. Explicit Control
+/// update calls can change current provider or multiplier values; the mission
+/// clock does not advance them automatically. Selector `6` first checks the
+/// current descriptor tag: a non-2 tag returns `Ok(None)`. For the represented
+/// factory-2 layout it reads `+0x174`, subject to the normal-or-zero float
+/// range checks; an unsupported field or layout returns a typed error. This is
+/// a query of the loaded CPU projection, not an FX execution or game tick.
///
/// # Errors
/// Returns an error for an invalid handle, unavailable mission/table/provider,
-/// an out-of-range device/provider key, or a factory/tag/float path that is not
-/// supported by the current reconstruction.
+/// an out-of-range device/provider key, an unsupported float range, or an
+/// unsupported current/factory tag on selector `0xe00`, or an unsupported
+/// selector-6 factory field/layout.
pub fn query_loaded_mission_control_device_selector(
engine: &Engine,
handle: ObjectHandle,
@@ -4127,7 +4631,7 @@ fn query_control_device_table_resource(
table: &MissionControlDeviceTableState,
selector: u32,
) -> Result<Option<f32>, ControlResourceAccessError> {
- if !matches!(selector, 1 | 2) {
+ if !matches!(selector, 1 | 2 | 15) {
return Err(ControlResourceAccessError::UnsupportedSelector(selector));
}
let entries = match &table.status {
@@ -4137,6 +4641,32 @@ fn query_control_device_table_resource(
}
};
+ if selector == 15 {
+ let Some(class9) = entries.iter().rev().find(|entry| entry.factory_tag == 9) else {
+ return Ok(None);
+ };
+ let Some(class21) = entries.iter().rev().find(|entry| entry.factory_tag == 21) else {
+ return Ok(None);
+ };
+
+ let mut sum = control_device_budget_coefficient(class21, 5)?;
+ for index in (0..5).rev() {
+ sum = checked_control_sum(
+ 21,
+ "sum",
+ sum,
+ control_device_budget_coefficient(class21, index)?,
+ )?;
+ }
+ return checked_control_product(
+ 9,
+ "selector-15 value",
+ control_device_selector_zero(class9)?,
+ sum,
+ )
+ .map(Some);
+ }
+
let mut numerator = 0.0_f32;
let mut denominator = 0.0_f32;
for entry in entries.iter().filter(|entry| entry.descriptor.tag == 19) {
@@ -4173,7 +4703,7 @@ fn query_control_device_table_selector(
device_index: usize,
selector: u32,
) -> Result<Option<f32>, ControlResourceAccessError> {
- if selector != 0xe00 {
+ if !matches!(selector, 6 | 0x400 | 0xe00) {
return Err(ControlResourceAccessError::UnsupportedSelector(selector));
}
let devices = match &table.status {
@@ -4189,6 +4719,21 @@ fn query_control_device_table_selector(
device_index,
device_count: devices.len(),
})?;
+ if selector == 0x400 {
+ return control_device_provider_value_from_status(&table.node_provider_status, device)
+ .map(Some);
+ }
+ if selector == 6 {
+ if device.descriptor.tag != 2 {
+ return Ok(None);
+ }
+ if device.factory_tag != 2 {
+ return Err(ControlResourceAccessError::UnsupportedFactoryTag {
+ factory_tag: device.factory_tag,
+ });
+ }
+ return control_device_float(device, device.device174_bits, "+0x174").map(Some);
+ }
query_control_device_selector(device, &table.node_provider_status)
}
@@ -5547,7 +6092,7 @@ fn append_effective_control_device(
} else {
Some(descriptor.fixed_words[6])
};
- devices.push(EffectiveControlDevice {
+ let device = EffectiveControlDevice {
factory_tag: descriptor.tag,
component_id,
key,
@@ -5555,6 +6100,7 @@ fn append_effective_control_device(
multiplier_bits,
class9_selector0_scale_bits: (descriptor.tag == 9).then_some(0x3f80_0000),
tag26_extra_rate_bits: (descriptor.tag == 26).then_some(0),
+ device174_bits: (descriptor.tag == 2).then_some(0),
class9_values_bits: (descriptor.tag == 9).then_some([0x3f80_0000; 6]),
class9_deltas_bits: (descriptor.tag == 9).then_some([0; 6]),
native_word_50_bits,
@@ -5562,7 +6108,65 @@ fn append_effective_control_device(
descriptor: descriptor.clone(),
factory_provenance: provenance.clone(),
descriptor_provenance: provenance,
- });
+ };
+ devices.push(device);
+}
+
+fn bounded_ascii_c_string(raw: &[u8]) -> Option<String> {
+ let end = raw.iter().position(|byte| *byte == 0)?;
+ let bytes = &raw[..end];
+ if !bytes.is_ascii() {
+ return None;
+ }
+ std::str::from_utf8(bytes).ok().map(str::to_owned)
+}
+
+fn fixed_words_ascii_c_string(words: &[u32]) -> Option<String> {
+ let bytes = words
+ .iter()
+ .flat_map(|word| word.to_le_bytes())
+ .collect::<Vec<_>>();
+ bounded_ascii_c_string(&bytes)
+}
+
+fn factory2_pair_from_record(
+ descriptor: &PreparedControlTypedRecord,
+) -> Option<Factory2StringPair> {
+ Some(Factory2StringPair {
+ archive: fixed_words_ascii_c_string(&descriptor.fixed_words[27..35])?,
+ name: fixed_words_ascii_c_string(&descriptor.fixed_words[35..43])?,
+ })
+}
+
+fn apply_factory2_pair_update(
+ device: &mut EffectiveControlDevice,
+ previous_pair: Option<Factory2StringPair>,
+ descriptor: &PreparedControlTypedRecord,
+ resolve_factory2_pair: &mut dyn FnMut(&Factory2StringPair) -> Factory2PairOutcome,
+) {
+ if device.factory_tag != 2 {
+ return;
+ }
+ let Some(pair) = factory2_pair_from_record(descriptor) else {
+ device.device174_bits = None;
+ return;
+ };
+ if previous_pair
+ .as_ref()
+ .is_some_and(|previous| previous.matches_ascii_case(&pair))
+ {
+ return;
+ }
+ let outcome = if pair.archive.is_empty() || pair.name.is_empty() {
+ Factory2PairOutcome::NativeMiss
+ } else {
+ resolve_factory2_pair(&pair)
+ };
+ match outcome {
+ Factory2PairOutcome::Value(bits) => device.device174_bits = Some(bits),
+ Factory2PairOutcome::NativeMiss => {}
+ Factory2PairOutcome::Unrepresentable => device.device174_bits = None,
+ }
}
fn apply_effective_control_override(
@@ -5655,6 +6259,7 @@ fn build_control_device_projection(
) -> Result<ControlDeviceProjection, MissionControlDeviceTableUnavailableReason> {
let native_class =
native_control_class_for_root(draft.unit_dat_header_flags, prototype_span, prototypes);
+ let mut resolve_factory2_pair = |_: &Factory2StringPair| Factory2PairOutcome::Unrepresentable;
build_control_device_projection_with_class(
object_index,
draft,
@@ -5662,6 +6267,7 @@ fn build_control_device_projection(
prototype_span,
prototypes,
native_class,
+ &mut resolve_factory2_pair,
)
}
@@ -5672,6 +6278,7 @@ fn build_control_device_projection_with_class(
prototype_span: Option<&std::ops::Range<usize>>,
prototypes: &[fparkan_prototype::EffectivePrototype],
native_control_class: Option<MissionControlClassProjection>,
+ resolve_factory2_pair: &mut dyn FnMut(&Factory2StringPair) -> Factory2PairOutcome,
) -> Result<ControlDeviceProjection, MissionControlDeviceTableUnavailableReason> {
use MissionControlDeviceTableUnavailableReason as Unavailable;
@@ -5980,6 +6587,12 @@ fn build_control_device_projection_with_class(
control,
descriptor,
);
+ apply_factory2_pair_update(
+ devices.last_mut().expect("append inserted one device"),
+ Some(Factory2StringPair::empty()),
+ descriptor,
+ resolve_factory2_pair,
+ );
initialize_control_profile_cache27(&mut profile_cache27_bits, descriptor);
}
continue;
@@ -6020,6 +6633,12 @@ fn build_control_device_projection_with_class(
control,
descriptor,
);
+ apply_factory2_pair_update(
+ devices.last_mut().expect("append inserted one device"),
+ Some(Factory2StringPair::empty()),
+ descriptor,
+ resolve_factory2_pair,
+ );
initialize_control_profile_cache27(&mut profile_cache27_bits, descriptor);
}
}
@@ -6048,6 +6667,13 @@ fn build_control_device_projection_with_class(
factory_tag: device.factory_tag,
});
}
+ let previous_pair = factory2_pair_from_record(&device.descriptor);
+ apply_factory2_pair_update(
+ device,
+ previous_pair,
+ descriptor,
+ resolve_factory2_pair,
+ );
apply_effective_control_override(device, descriptor, component_index, 0, control);
update_control_profile_cache27_on_into(&mut profile_cache27_bits, descriptor);
}
@@ -6332,6 +6958,91 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPa
})
}
+fn script_bundle_stem(base_path: &str) -> &str {
+ let suffix_start = base_path.len().saturating_sub(4);
+ if base_path
+ .get(suffix_start..)
+ .is_some_and(|suffix| suffix.eq_ignore_ascii_case(".scr"))
+ {
+ &base_path[..suffix_start]
+ } else {
+ base_path
+ }
+}
+
+fn script_sibling_path(base_path: &str, extension: &str) -> String {
+ format!("{}.{extension}", script_bundle_stem(base_path))
+}
+
+fn load_script_formula_table(
+ vfs: &Arc<dyn Vfs>,
+ base_path: &str,
+) -> Result<Option<FormulaTable>, EngineError> {
+ let path = normalize_engine_path("mission formula", &script_sibling_path(base_path, "fml"))?;
+ match vfs.read(&path) {
+ Ok(bytes) => Ok(Some(FormulaTable::parse(&bytes))),
+ Err(VfsError::NotFound(_)) => Ok(None),
+ Err(source) => Err(EngineError::Vfs {
+ path: path.as_str().to_string(),
+ source,
+ }),
+ }
+}
+
+fn load_mission_research_trf_tables(
+ vfs: &Arc<dyn Vfs>,
+ mission: &MissionDocument,
+ ai_startup_states: &[MissionAiStartupState],
+) -> Result<Vec<MissionResearchTrfTable>, EngineError> {
+ let mut tables = Vec::with_capacity(ai_startup_states.len());
+ for ai_state in ai_startup_states {
+ let Some(clan) = mission.clans.get(ai_state.clan_index) else {
+ return Err(EngineError::ScriptRuntime {
+ clan_index: ai_state.clan_index,
+ message: "AI startup state clan index is outside the decoded mission".to_string(),
+ });
+ };
+ let second_resource = match &clan.body {
+ ClanBody::Standard {
+ second_resource, ..
+ }
+ | ClanBody::Spatial {
+ second_resource, ..
+ } => second_resource,
+ };
+ let raw_path = second_resource
+ .path
+ .raw
+ .split(|byte| *byte == 0)
+ .next()
+ .unwrap_or_default();
+ if raw_path.is_empty() {
+ continue;
+ }
+ if !raw_path.is_ascii() {
+ return Err(EngineError::ResearchTrfPath {
+ clan_index: ai_state.clan_index,
+ path: raw_path.to_vec(),
+ });
+ }
+ let value = std::str::from_utf8(raw_path).expect("ASCII paths are valid UTF-8");
+ let path = normalize_engine_path("mission research TRF", value)?;
+ let bytes = read_vfs(vfs, &path)?;
+ let document =
+ ResearchTrfDocument::decode(bytes).map_err(|source| EngineError::ResearchTrf {
+ clan_index: ai_state.clan_index,
+ path: path.as_str().to_string(),
+ source,
+ })?;
+ tables.push(MissionResearchTrfTable {
+ clan_index: ai_state.clan_index,
+ path: path.as_str().to_string(),
+ document,
+ });
+ }
+ Ok(tables)
+}
+
fn load_mission_scripts(
vfs: &Arc<dyn Vfs>,
mission: &MissionDocument,
@@ -6340,6 +7051,54 @@ fn load_mission_scripts(
let script_bases = mission_script_bundle_bases(mission);
let mut bundles = Vec::with_capacity(script_bases.len());
let mut first_base_path = None;
+ let mut ai_startup_states = Vec::new();
+ if asset_scope == MissionAssetScope::Full {
+ let mut allocated_slots = 0usize;
+ for (clan_index, clan) in mission.clans.iter().enumerate() {
+ let second_resource = match &clan.body {
+ ClanBody::Standard {
+ second_resource, ..
+ }
+ | ClanBody::Spatial {
+ second_resource, ..
+ } => second_resource,
+ };
+ let raw_count = second_resource.tag as u32;
+ let count =
+ usize::try_from(raw_count).map_err(|_| EngineError::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message: "count does not fit this platform's address space".to_string(),
+ })?;
+ let requested_total = allocated_slots.checked_add(count);
+ if requested_total.is_none_or(|total| total > MAX_MISSION_AI_RECORD_SLOTS) {
+ return Err(EngineError::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message: format!(
+ "cumulative mission count exceeds the Rust allocation budget of {MAX_MISSION_AI_RECORD_SLOTS} u32 slots"
+ ),
+ });
+ }
+ let mut record_slots = Vec::new();
+ record_slots.try_reserve_exact(count).map_err(|source| {
+ EngineError::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message: format!("could not reserve {count} u32 slots: {source}"),
+ }
+ })?;
+ record_slots.resize(count, u32::MAX);
+ allocated_slots = requested_total.expect("checked cumulative AI slot count");
+ ai_startup_states.push(MissionAiStartupState {
+ clan_index,
+ record_slots,
+ });
+ }
+ }
for script_base in script_bases {
let base_path = String::from_utf8_lossy(&script_base.path_raw).into_owned();
let script_path = if base_path.to_ascii_lowercase().ends_with(".scr") {
@@ -6353,6 +7112,7 @@ fn load_mission_scripts(
path: normalized.as_str().to_string(),
message: source.to_string(),
})?;
+ let formula_table = load_script_formula_table(vfs, &base_path)?;
if first_base_path.is_none() {
first_base_path = Some(base_path.clone());
}
@@ -6361,6 +7121,7 @@ fn load_mission_scripts(
base_path,
script_path: normalized.as_str().to_string(),
package,
+ formula_table,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
});
@@ -6372,7 +7133,7 @@ fn load_mission_scripts(
let (init_states, varset_states) = if asset_scope == MissionAssetScope::Full {
(Vec::new(), Vec::new())
} else if let Some(varset) = &varset {
- let init_states = resolve_handler19_init_states(
+ let init_states = resolve_init_states(
&mission
.clans
.iter()
@@ -6389,6 +7150,7 @@ fn load_mission_scripts(
};
Ok(LoadedMissionScripts {
bundles,
+ ai_startup_states,
varset,
init_states,
varset_states,
@@ -6417,7 +7179,7 @@ fn start_mission_scripts(
));
if let Some(varset) = scripts.varset.as_ref() {
- init_states.extend(resolve_handler19_init_states(
+ init_states.extend(resolve_init_states(
&clan_anchors,
std::slice::from_mut(bundle),
varset,
@@ -6459,12 +7221,12 @@ fn materialize_script_varset_states(
return Err(EngineError::ScriptInit {
clan_index: bundle.clan_index,
message: format!(
- "Handler(19) write index {} is outside the instantiated varset",
+ "Init write index {} is outside the instantiated varset",
write.index
),
});
};
- *value = VarSetDefault::Dword(write.value);
+ *value = write.value;
}
}
Ok(MissionScriptVarSetState {
@@ -6475,7 +7237,7 @@ fn materialize_script_varset_states(
.collect()
}
-fn resolve_handler19_init_states(
+fn resolve_init_states(
clan_anchors: &[[f32; 2]],
bundles: &mut [MissionScriptBundle],
varset: &MissionScriptVarSet,
@@ -6508,15 +7270,15 @@ fn resolve_handler19_init_states(
};
match varset
.declarations
- .resolve_handler19_init(&bundle.package, input)
+ .resolve_init(&bundle.package, input, bundle.formula_table.as_ref())
.map_err(|source| EngineError::ScriptInit {
clan_index: bundle.clan_index,
message: source.to_string(),
})? {
- Handler19InitResolution::Missing => {
+ ScriptInitResolution::Missing => {
bundle.init_execution = MissionScriptInitExecution::Missing;
}
- Handler19InitResolution::Executed {
+ ScriptInitResolution::Executed {
event_index,
event_word,
writes,
@@ -6531,7 +7293,7 @@ fn resolve_handler19_init_states(
});
}
}
- Handler19InitResolution::Unsupported {
+ ScriptInitResolution::Unsupported {
event_index,
instruction_index,
error,
@@ -6542,7 +7304,7 @@ fn resolve_handler19_init_states(
error,
};
}
- Handler19InitResolution::Unsupported {
+ ScriptInitResolution::Unsupported {
event_index,
instruction_index,
error,
@@ -6631,9 +7393,983 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi
#[cfg(test)]
mod tests {
use super::*;
- use fparkan_script::ScriptDispatchSelector;
+ use fparkan_assets::PreparedControlPointList;
use fparkan_vfs::{DirectoryVfs, MemoryVfs, OverlayVfs, VfsEntry, VfsMetadata};
use std::path::{Path, PathBuf};
+ #[test]
+ fn factory2_selector6_reads_fresh_constructor_zero() {
+ let device = synthetic_effective_control_device(2, 2, 0, None, None);
+ assert!(device.descriptor.fixed_words[27..43]
+ .iter()
+ .all(|word| *word == 0));
+ let table =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ device,
+ ]));
+
+ assert_eq!(
+ query_control_device_table_selector(&table, 0, 6),
+ Ok(Some(0.0))
+ );
+ }
+
+ #[test]
+ fn factory2_selector6_returns_the_stored_float_word() {
+ let mut device = synthetic_effective_control_device(2, 2, 0, None, None);
+ device.device174_bits = Some(6.25_f32.to_bits());
+ let table =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ device,
+ ]));
+
+ assert_eq!(
+ query_control_device_table_selector(&table, 0, 6),
+ Ok(Some(6.25))
+ );
+ }
+
+ #[test]
+ fn factory2_selector6_preserves_stored_negative_zero() {
+ let mut device = synthetic_effective_control_device(2, 2, 0, None, None);
+ device.device174_bits = Some((-0.0_f32).to_bits());
+ let table =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ device,
+ ]));
+
+ assert_eq!(
+ query_control_device_table_selector(&table, 0, 6)
+ .expect("negative zero is a valid native float")
+ .expect("tag 2 has a selector-6 value")
+ .to_bits(),
+ (-0.0_f32).to_bits()
+ );
+ }
+
+ #[test]
+ fn factory2_selector6_reports_missing_wrong_factory_nonfinite_and_subnormal_values() {
+ let table_for = |device| {
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![device]))
+ };
+
+ let mut missing = synthetic_effective_control_device(2, 2, 0, None, None);
+ missing.device174_bits = None;
+ assert_eq!(
+ query_control_device_table_selector(&table_for(missing), 0, 6),
+ Err(ControlResourceAccessError::UnsupportedField {
+ factory_tag: 2,
+ field: "+0x174",
+ })
+ );
+
+ let mut wrong_factory = synthetic_effective_control_device(5, 2, 0, None, None);
+ wrong_factory.device174_bits = Some(1.0_f32.to_bits());
+ assert_eq!(
+ query_control_device_table_selector(&table_for(wrong_factory), 0, 6),
+ Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag: 5 })
+ );
+
+ let mut nonfinite = synthetic_effective_control_device(2, 2, 0, None, None);
+ nonfinite.device174_bits = Some(f32::INFINITY.to_bits());
+ assert_eq!(
+ query_control_device_table_selector(&table_for(nonfinite), 0, 6),
+ Err(ControlResourceAccessError::NonFiniteValue {
+ factory_tag: 2,
+ field: "+0x174",
+ })
+ );
+
+ let mut subnormal = synthetic_effective_control_device(2, 2, 0, None, None);
+ subnormal.device174_bits = Some(1);
+ assert_eq!(
+ query_control_device_table_selector(&table_for(subnormal), 0, 6),
+ Err(ControlResourceAccessError::SubnormalValue {
+ factory_tag: 2,
+ field: "+0x174",
+ })
+ );
+ }
+
+ #[test]
+ fn factory2_selector6_returns_no_result_for_a_non_two_current_tag() {
+ let mut device = synthetic_effective_control_device(5, 5, 0, None, None);
+ device.device174_bits = None;
+ let table =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ device,
+ ]));
+
+ assert_eq!(query_control_device_table_selector(&table, 0, 6), Ok(None));
+ }
+
+ #[test]
+ fn factory2_pair_updates_keep_append_and_into_order_and_native_miss_bits() {
+ let mut root_record = synthetic_control_record(2, 0, 0, None);
+ set_factory2_pair(&mut root_record, b"objects.rlb", b"root");
+ let mut same_record = synthetic_control_record(2, 0, 0, None);
+ set_factory2_pair(&mut same_record, b"OBJECTS.RLB", b"ROOT");
+ let mut changed_record = synthetic_control_record(2, 0, 0, None);
+ set_factory2_pair(&mut changed_record, b"objects.rlb", b"second");
+ let mut empty_record = synthetic_control_record(2, 0, 0, None);
+ set_factory2_pair(&mut empty_record, b"objects.rlb", b"");
+ let controls = [root_record, same_record, changed_record, empty_record]
+ .into_iter()
+ .enumerate()
+ .map(|(index, record)| {
+ Some(synthetic_component_control(
+ index,
+ vec![record],
+ PreparedControlEffectSection::Parsed(Vec::new()),
+ ))
+ })
+ .collect();
+ let (draft, assets, prototypes) = synthetic_control_fixture(
+ &[
+ None,
+ Some(PROTOTYPE_TYPE_INTO),
+ Some(PROTOTYPE_TYPE_INTO),
+ Some(PROTOTYPE_TYPE_INTO),
+ ],
+ &[1, 1, 1, 1],
+ &[3, 0, 0, 0],
+ &[0, 0, 0, 0],
+ &[1, 0, 0, 0],
+ controls,
+ );
+ let mut resolved = Vec::new();
+ let mut resolver = |pair: &Factory2StringPair| {
+ resolved.push((pair.archive.clone(), pair.name.clone()));
+ match pair.name.as_str() {
+ "root" => Factory2PairOutcome::Value(6.25_f32.to_bits()),
+ "second" => Factory2PairOutcome::Value(9.5_f32.to_bits()),
+ _ => Factory2PairOutcome::Unrepresentable,
+ }
+ };
+
+ let projection = build_control_device_projection_with_class(
+ 0,
+ &draft,
+ &assets,
+ Some(&(0..prototypes.len())),
+ &prototypes,
+ None,
+ &mut resolver,
+ )
+ .expect("factory-2 append plus three INTO updates");
+
+ assert_eq!(
+ resolved,
+ [
+ ("objects.rlb".to_string(), "root".to_string()),
+ ("objects.rlb".to_string(), "second".to_string())
+ ],
+ "same pair skips recalculation and an empty name is a native miss"
+ );
+ assert_eq!(projection.devices.len(), 1);
+ assert_eq!(projection.devices[0].factory_tag, 2);
+ assert_eq!(
+ projection.devices[0].device174_bits,
+ Some(9.5_f32.to_bits())
+ );
+ assert_eq!(
+ projection.devices[0]
+ .descriptor_provenance
+ .unit_component_index,
+ 3,
+ "INTO retains the last raw descriptor even when selector state is preserved"
+ );
+ }
+
+ #[test]
+ fn factory2_pair_parser_validates_nul_and_ascii_before_it() {
+ let mut record = synthetic_control_record(2, 0, 0, None);
+ set_factory2_pair(&mut record, b"objects.rlb", b"name");
+ assert_eq!(
+ factory2_pair_from_record(&record).map(|pair| (pair.archive, pair.name)),
+ Some(("objects.rlb".to_string(), "name".to_string()))
+ );
+
+ record.fixed_words[27..35].fill(u32::from_le_bytes(*b"ABCD"));
+ assert!(factory2_pair_from_record(&record).is_none(), "missing NUL");
+
+ record.fixed_words[27] = u32::from_le_bytes([0xff, 0, 0, 0]);
+ assert!(
+ factory2_pair_from_record(&record).is_none(),
+ "non-ASCII byte"
+ );
+
+ record.fixed_words[27] = u32::from_le_bytes([b'a', 0, b'b', 0]);
+ let first = factory2_pair_from_record(&record).expect("NUL ends the archive string");
+ assert_eq!(first.archive, "a");
+ let mut different_padding = record.clone();
+ different_padding.fixed_words[27] = u32::from_le_bytes([b'A', 0, b'c', 0]);
+ let second = factory2_pair_from_record(&different_padding)
+ .expect("native C-string comparison ignores bytes after NUL");
+ assert!(first.matches_ascii_case(&second));
+ }
+
+ #[test]
+ fn factory2_unrepresentable_pair_clears_only_the_available_projection() {
+ let mut device = synthetic_effective_control_device(2, 2, 0, None, None);
+ device.device174_bits = Some(6.25_f32.to_bits());
+ let mut replacement = synthetic_control_record(2, 0, 0, None);
+ set_factory2_pair(&mut device.descriptor, b"objects.rlb", b"known");
+ set_factory2_pair(&mut replacement, b"objects.rlb", b"unknown");
+ let previous_pair = factory2_pair_from_record(&device.descriptor);
+ let mut resolver = |_: &Factory2StringPair| Factory2PairOutcome::Unrepresentable;
+ apply_factory2_pair_update(&mut device, previous_pair, &replacement, &mut resolver);
+
+ assert_eq!(device.device174_bits, None);
+ assert_eq!(
+ control_device_float(&device, device.device174_bits, "+0x174"),
+ Err(ControlResourceAccessError::UnsupportedField {
+ factory_tag: 2,
+ field: "+0x174",
+ })
+ );
+ }
+
+ #[test]
+ fn factory2_pair_cache_key_folds_ascii_case() {
+ let upper = Factory2StringPair {
+ archive: "OBJECTS.RLB".to_string(),
+ name: "FOREIGN".to_string(),
+ };
+ let lower = Factory2StringPair {
+ archive: "objects.rlb".to_string(),
+ name: "foreign".to_string(),
+ };
+
+ assert_eq!(upper.cache_key(), lower.cache_key());
+ }
+
+ #[test]
+ fn factory2_ndp_maximum_uses_ordered_f32_sum_and_accepts_empty_tables() {
+ let mut rows = [0.0_f32; 3].map(|max_volume| PreparedControlNodeDataRow {
+ raw_flags: 0,
+ max_volume,
+ mobility_per_volume: 0.0,
+ effect: PreparedControlResourcePair {
+ archive: [0; 32],
+ name: [0; 32],
+ },
+ });
+ rows[0].max_volume = 16_777_216.0;
+ rows[1].max_volume = 1.0;
+ rows[2].max_volume = -16_777_216.0;
+
+ assert_eq!(
+ factory2_node_data_maximum_bits(&rows),
+ Ok(0.0_f32.to_bits())
+ );
+ assert_eq!(factory2_node_data_maximum_bits(&[]), Ok(0.0_f32.to_bits()));
+ assert_eq!(
+ factory2_node_data_maximum_bits(&[PreparedControlNodeDataRow {
+ max_volume: f32::from_bits(1),
+ ..rows[0].clone()
+ }]),
+ Err("NDP Max float")
+ );
+ }
+
+ fn factory2_test_nres(entries: &[(&[u8], u32, u32, &[u8])]) -> Vec<u8> {
+ let mut bytes = vec![0; 16];
+ let mut offsets = Vec::with_capacity(entries.len());
+ for (_, _, _, payload) in entries {
+ offsets.push(u32::try_from(bytes.len()).expect("payload offset fits u32"));
+ bytes.extend_from_slice(payload);
+ bytes.resize(bytes.len() + (8 - bytes.len() % 8) % 8, 0);
+ }
+ let mut sorted_indices = (0..entries.len()).collect::<Vec<_>>();
+ sorted_indices.sort_by(|left, right| entries[*left].0.cmp(entries[*right].0));
+ let mut order = vec![0_u32; entries.len()];
+ for (rank, index) in sorted_indices.into_iter().enumerate() {
+ order[index] = u32::try_from(rank).expect("entry rank fits u32");
+ }
+ for (index, (name, type_id, attr3, payload)) in entries.iter().enumerate() {
+ assert!(name.len() < 36, "NRes fixture name needs a bounded NUL");
+ for word in [
+ *type_id,
+ 0,
+ 0,
+ u32::try_from(payload.len()).expect("payload length fits u32"),
+ *attr3,
+ ] {
+ bytes.extend_from_slice(&word.to_le_bytes());
+ }
+ let mut name_raw = [0; 36];
+ name_raw[..name.len()].copy_from_slice(name);
+ bytes.extend_from_slice(&name_raw);
+ bytes.extend_from_slice(&offsets[index].to_le_bytes());
+ bytes.extend_from_slice(&order[index].to_le_bytes());
+ }
+ bytes[0..4].copy_from_slice(b"NRes");
+ bytes[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
+ bytes[8..12].copy_from_slice(
+ &u32::try_from(entries.len())
+ .expect("entry count fits u32")
+ .to_le_bytes(),
+ );
+ let total_size = u32::try_from(bytes.len()).expect("archive size fits u32");
+ bytes[12..16].copy_from_slice(&total_size.to_le_bytes());
+ bytes
+ }
+
+ fn factory2_test_object_refs(items: &[(&[u8], &[u8])]) -> Vec<u8> {
+ let mut bytes = Vec::with_capacity(items.len() * 64);
+ for (archive, name) in items {
+ assert!(archive.len() < 32 && name.len() < 32);
+ let mut record = [0; 64];
+ record[..archive.len()].copy_from_slice(archive);
+ record[32..32 + name.len()].copy_from_slice(name);
+ bytes.extend_from_slice(&record);
+ }
+ bytes
+ }
+
+ fn factory2_test_control_payload(
+ pair: Option<(&[u8], &[u8])>,
+ effect_opcode: Option<u32>,
+ ) -> Vec<u8> {
+ let mut payload = vec![0; 5 * 4 + 108];
+ if let Some((archive, name)) = pair {
+ payload[12..16].copy_from_slice(&1_u32.to_le_bytes());
+ let record_start = payload.len();
+ payload.resize(record_start + 0xb0 + 8, 0);
+ payload[record_start..record_start + 4].copy_from_slice(&2_u32.to_le_bytes());
+ let archive_start = record_start + 27 * 4;
+ payload[archive_start..archive_start + archive.len()].copy_from_slice(archive);
+ let name_start = record_start + 35 * 4;
+ payload[name_start..name_start + name.len()].copy_from_slice(name);
+ }
+ if let Some(opcode) = effect_opcode {
+ payload[16..20].copy_from_slice(&1_u32.to_le_bytes());
+ let group_start = payload.len();
+ payload.resize(group_start + 0x54 + 4 + 0x64, 0);
+ payload[group_start + 0x54..group_start + 0x58].copy_from_slice(&1_u32.to_le_bytes());
+ payload[group_start + 0x58 + 0x0c..group_start + 0x58 + 0x10]
+ .copy_from_slice(&opcode.to_le_bytes());
+ }
+ payload
+ }
+
+ fn factory2_test_mesh_payload() -> Vec<u8> {
+ let res2 = vec![0; 0x8c];
+ factory2_test_nres(&[
+ (b"Res1", 1, 38, &[]),
+ (b"Res13", 13, 0, &[]),
+ (b"Res2", 2, 0, &res2),
+ (b"Res3", 3, 0, &[]),
+ (b"Res6", 6, 0, &[]),
+ ])
+ }
+
+ fn factory2_test_mat0() -> Vec<u8> {
+ let mut bytes = vec![0; 4 + 34];
+ bytes[0..2].copy_from_slice(&1_u16.to_le_bytes());
+ bytes[22..27].copy_from_slice(b"TEX_A");
+ bytes
+ }
+
+ fn factory2_test_texm() -> Vec<u8> {
+ let mut bytes = Vec::new();
+ for word in [0x6d78_6554_u32, 1, 1, 1, 0, 0, 0, 565] {
+ bytes.extend_from_slice(&word.to_le_bytes());
+ }
+ bytes.extend_from_slice(&0xffff_u16.to_le_bytes());
+ bytes
+ }
+
+ fn factory2_test_resolver_archives(
+ foreign_control: &[u8],
+ ndp_maxima: &[f32],
+ ) -> Vec<(&'static str, Vec<u8>)> {
+ let host_refs = factory2_test_object_refs(&[
+ (b"static.rlb", b"model.msh"),
+ (b"controls.rlb", b"host.ctl"),
+ ]);
+ let foreign_refs = factory2_test_object_refs(&[
+ (b"static.rlb", b"model.msh"),
+ (b"controls.rlb", b"foreign.ctl"),
+ (b"providers.rlb", b"foreign.ndp"),
+ ]);
+ let objects = factory2_test_nres(&[
+ (b"foreign", CONTROL_CLASS_SOURCE_BULL, 0, &foreign_refs),
+ (b"host", CONTROL_CLASS_SOURCE_BULL, 0, &host_refs),
+ ]);
+ let mesh = factory2_test_mesh_payload();
+ let static_archive = factory2_test_nres(&[
+ (b"model.msh", 0, 0, &mesh),
+ (b"model.wea", 0x5241_4557, 0, b"1\n0 MAT_A\n"),
+ ]);
+ let host_control = factory2_test_control_payload(Some((b"objects.rlb", b"foreign")), None);
+ let control_archive = factory2_test_nres(&[
+ (b"foreign.ctl", 0, 0, foreign_control),
+ (b"host.ctl", 0, 0, &host_control),
+ ]);
+ let mut node_data = vec![0; 4 + ndp_maxima.len() * 76];
+ node_data[..4].copy_from_slice(
+ &u32::try_from(ndp_maxima.len())
+ .expect("NDP count fits u32")
+ .to_le_bytes(),
+ );
+ for (index, maximum) in ndp_maxima.iter().enumerate() {
+ let start = 4 + index * 76;
+ node_data[start + 4..start + 8].copy_from_slice(&maximum.to_bits().to_le_bytes());
+ }
+ let node_data_archive = factory2_test_nres(&[(b"foreign.ndp", 0, 0, &node_data)]);
+ let mat0 = factory2_test_mat0();
+ let material_archive = factory2_test_nres(&[(b"MAT_A", 0x3054_414D, 0, &mat0)]);
+ let texm = factory2_test_texm();
+ let texture_archive = factory2_test_nres(&[(b"TEX_A", 0, 0, &texm)]);
+ vec![
+ ("objects.rlb", objects),
+ ("static.rlb", static_archive),
+ ("controls.rlb", control_archive),
+ ("providers.rlb", node_data_archive),
+ ("material.lib", material_archive),
+ ("textures.lib", texture_archive),
+ ]
+ }
+
+ fn factory2_test_resolver_vfs(foreign_control: &[u8]) -> Arc<dyn Vfs> {
+ factory2_test_resolver_vfs_with_maxima(foreign_control, &[])
+ }
+
+ fn factory2_test_resolver_vfs_with_maxima(
+ foreign_control: &[u8],
+ ndp_maxima: &[f32],
+ ) -> Arc<dyn Vfs> {
+ let mut files = MemoryVfs::default();
+ for (path, bytes) in factory2_test_resolver_archives(foreign_control, ndp_maxima) {
+ files.insert(
+ normalize_engine_path("factory-2 resolver fixture", path).expect("archive path"),
+ Arc::from(bytes.into_boxed_slice()),
+ );
+ }
+ Arc::new(files)
+ }
+
+ fn factory2_test_fxid_payload(opcodes: &[u32]) -> Vec<u8> {
+ let command_size = |opcode| match opcode {
+ 1 => 224,
+ 2 => 148,
+ 3 => 200,
+ 4 => 204,
+ 5 => 112,
+ 6 => 4,
+ 7 | 9 | 10 => 208,
+ 8 => 248,
+ _ => panic!("fixture opcode {opcode} is not framed"),
+ };
+ let mut payload = vec![0; 60];
+ payload[..4].copy_from_slice(
+ &u32::try_from(opcodes.len())
+ .expect("FX command count fits u32")
+ .to_le_bytes(),
+ );
+ for opcode in opcodes {
+ let start = payload.len();
+ payload.resize(start + command_size(*opcode), 0);
+ payload[start..start + 4].copy_from_slice(&opcode.to_le_bytes());
+ }
+ payload
+ }
+
+ fn factory2_test_fxid_manager(
+ entries: &[(&[u8], u32, &[u8])],
+ ) -> AssetManager<CachedResourceRepository> {
+ let archive_entries = entries
+ .iter()
+ .map(|(name, type_id, payload)| (*name, *type_id, 0, *payload))
+ .collect::<Vec<_>>();
+ let archive = factory2_test_nres(&archive_entries);
+ let mut files = MemoryVfs::default();
+ files.insert(
+ normalize_engine_path("factory-2 FX fixture", "effects.rlb").expect("FX archive path"),
+ Arc::from(archive.into_boxed_slice()),
+ );
+ let vfs: Arc<dyn Vfs> = Arc::new(files);
+ AssetManager::new(CachedResourceRepository::new(vfs))
+ }
+
+ fn factory2_test_effect_resource_pair(
+ archive: &[u8],
+ name: &[u8],
+ ) -> PreparedControlResourcePair {
+ fn fixed_string(raw: &[u8]) -> [u8; 32] {
+ assert!(raw.len() <= 32);
+ let mut bytes = [0; 32];
+ bytes[..raw.len()].copy_from_slice(raw);
+ bytes
+ }
+
+ PreparedControlResourcePair {
+ archive: fixed_string(archive),
+ name: fixed_string(name),
+ }
+ }
+
+ fn factory2_test_effect_row(
+ table_index: usize,
+ row_index: usize,
+ archive: &[u8],
+ name: &[u8],
+ point_offsets: [u32; 3],
+ ) -> PreparedControlEffectRow {
+ PreparedControlEffectRow {
+ table_index,
+ row_index,
+ raw_condition: [0; 3],
+ point_offsets,
+ selector: 0,
+ resource: factory2_test_effect_resource_pair(archive, name),
+ }
+ }
+
+ fn factory2_test_control_effects(
+ rows: Vec<PreparedControlEffectRow>,
+ initial_effect_group: Option<usize>,
+ point_count: Option<usize>,
+ ) -> PreparedComponentControl {
+ PreparedComponentControl {
+ source: synthetic_resource_key(940),
+ native_profile_words: [0; 27],
+ model_node_count: 3,
+ bindings: Vec::new(),
+ timeline_rows: Vec::new(),
+ typed_records: Vec::new(),
+ node_data: None,
+ effect_section: PreparedControlEffectSection::Parsed(rows),
+ initial_effect_group,
+ point_list: point_count.map(|count| PreparedControlPointList {
+ source: synthetic_resource_key(941),
+ points: (0..count)
+ .map(|_| fparkan_assets::PreparedControlPoint {
+ raw_flags: 0,
+ raw_signed_field_04: 0,
+ raw_signed_field_08: 0,
+ position: [0.0; 3],
+ direction: [0.0; 3],
+ name: [0; 32],
+ })
+ .collect(),
+ }),
+ }
+ }
+
+ fn factory2_test_full_load_vfs(foreign_control: &[u8]) -> Arc<dyn Vfs> {
+ factory2_test_full_load_vfs_with_maxima(foreign_control, &[])
+ }
+
+ fn factory2_test_full_load_vfs_with_maxima(
+ foreign_control: &[u8],
+ ndp_maxima: &[f32],
+ ) -> Arc<dyn Vfs> {
+ fn push_u32(bytes: &mut Vec<u8>, value: u32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+ fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) {
+ push_u32(bytes, u32::try_from(value.len()).expect("length fits u32"));
+ bytes.extend_from_slice(value);
+ }
+ fn push_f32(bytes: &mut Vec<u8>, value: f32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ let mut mission = Vec::new();
+ push_u32(&mut mission, 1);
+ push_u32(&mut mission, 0);
+ push_u32(&mut mission, 6);
+ push_u32(&mut mission, 1);
+ push_lp(&mut mission, b"Synthetic clan");
+ push_u32(&mut mission, u32::MAX);
+ push_f32(&mut mission, 0.0);
+ push_f32(&mut mission, 0.0);
+ push_u32(&mut mission, 1);
+ push_lp(&mut mission, b"MISSIONS/SCRIPTS/shared");
+ push_u32(&mut mission, 0);
+ push_lp(&mut mission, b"");
+ push_u32(&mut mission, 0);
+ push_u32(&mut mission, 0);
+ push_u32(&mut mission, 10);
+ push_u32(&mut mission, 1);
+ push_u32(&mut mission, 1);
+ push_u32(&mut mission, 0x8000_0002);
+ push_lp(&mut mission, b"UNITS/FACTORY2/host.dat");
+ push_u32(&mut mission, 0);
+ push_u32(&mut mission, 0);
+ for value in [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0] {
+ push_f32(&mut mission, value);
+ }
+ push_lp(&mut mission, b"host");
+ push_u32(&mut mission, 0);
+ push_u32(&mut mission, 0);
+ push_u32(&mut mission, 0);
+ push_u32(&mut mission, 1);
+ push_u32(&mut mission, 0);
+ push_lp(&mut mission, b"DATA\\MAPS\\SYNTHETIC\\land");
+ push_u32(&mut mission, 0);
+ push_lp(&mut mission, b"");
+ push_u32(&mut mission, 1);
+ push_u32(&mut mission, 0);
+
+ let mut unit_dat = vec![0; 8 + 112];
+ unit_dat[4..8].copy_from_slice(&0_u32.to_le_bytes());
+ unit_dat[8..19].copy_from_slice(b"objects.rlb");
+ unit_dat[40..44].copy_from_slice(b"host");
+ unit_dat[76..80].copy_from_slice(&0_i32.to_le_bytes());
+
+ let mut files = MemoryVfs::default();
+ for (path, bytes) in [
+ ("MISSIONS/TEST/data.tma", mission),
+ ("UNITS/FACTORY2/host.dat", unit_dat),
+ ("DATA/MAPS/SYNTHETIC/Land.msh", empty_land_mesh()),
+ ("DATA/MAPS/SYNTHETIC/Land.map", minimal_land_map()),
+ (
+ "MISSIONS/SCRIPTS/shared.scr",
+ encode_script_package(&ScriptPackage {
+ opcode_handler_count: 73,
+ events: Vec::new(),
+ trailing_bytes: Vec::new(),
+ raw: Arc::from(&b""[..]),
+ }),
+ ),
+ (
+ "MISSIONS/SCRIPTS/shared.var",
+ b"VAR(DWORD, value, 0)\n".to_vec(),
+ ),
+ ] {
+ files.insert(
+ normalize_engine_path("factory-2 full-load fixture", path).expect("test path"),
+ Arc::from(bytes.into_boxed_slice()),
+ );
+ }
+ for (path, bytes) in factory2_test_resolver_archives(foreign_control, ndp_maxima) {
+ files.insert(
+ normalize_engine_path("factory-2 full-load archive", path).expect("archive path"),
+ Arc::from(bytes.into_boxed_slice()),
+ );
+ }
+ files.insert(
+ normalize_engine_path("factory-2 full-load BuildDat", "BuildDat.lst")
+ .expect("BuildDat path"),
+ Arc::from(&b""[..]),
+ );
+ Arc::new(files)
+ }
+
+ #[test]
+ fn factory2_resolver_accepts_empty_effects_bull_and_ordered_ndp_rows() {
+ let control = factory2_test_control_payload(None, None);
+ let vfs =
+ factory2_test_resolver_vfs_with_maxima(&control, &[16_777_216.0, 1.0, -16_777_216.0]);
+ let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone()));
+ let pair = Factory2StringPair {
+ archive: "OBJECTS.RLB".to_string(),
+ name: "FOREIGN".to_string(),
+ };
+ assert_eq!(
+ resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair)
+ .expect("valid foreign BULL projection"),
+ Factory2PairOutcome::Value(0.0_f32.to_bits())
+ );
+ }
+
+ #[test]
+ fn factory2_fx_guard_accepts_supported_commands_only_from_selected_group() {
+ let fxid = factory2_test_fxid_payload(&[2, 3, 4, 5, 7, 8, 9]);
+ let manager = factory2_test_fxid_manager(&[(b"selected", FXID_KIND, &fxid)]);
+ let mut selected = factory2_test_effect_row(0, 0, b"effects.rlb", b"selected", [0, 1, 2]);
+ selected.resource.archive[12] = 0xff;
+ selected.resource.name[9] = 0xfe;
+ let unselected = factory2_test_effect_row(1, 0, b"missing.rlb", b"missing", [0, 1, 2]);
+ let control = factory2_test_control_effects(vec![selected, unselected], Some(0), Some(3));
+
+ assert!(
+ factory2_selected_fx_resources_are_representable(&manager, &control, &[4])
+ .expect("valid selected FX resource")
+ );
+ }
+
+ #[test]
+ fn factory2_fx_guard_rejects_unknown_types_missing_inputs_and_bad_points() {
+ for opcode in [1, 6, 10] {
+ let fxid = factory2_test_fxid_payload(&[opcode]);
+ let manager = factory2_test_fxid_manager(&[(b"effect", FXID_KIND, &fxid)]);
+ let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]);
+ let control = factory2_test_control_effects(vec![row], Some(0), Some(1));
+ assert!(
+ !factory2_selected_fx_resources_are_representable(&manager, &control, &[4])
+ .expect("framed unknown FX command remains unsupported")
+ );
+ }
+
+ let fxid = factory2_test_fxid_payload(&[2]);
+ let manager = factory2_test_fxid_manager(&[(b"effect", FXID_KIND, &fxid)]);
+ for (point_offsets, point_count) in [
+ ([0, 1, 0], Some(1)),
+ ([0, u32::MAX, 0], Some(1)),
+ ([0, 0, 0], None),
+ ] {
+ let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", point_offsets);
+ let control = factory2_test_control_effects(vec![row], Some(0), point_count);
+ assert!(
+ !factory2_selected_fx_resources_are_representable(&manager, &control, &[4])
+ .expect("unsupported CPT selector remains unknown")
+ );
+ }
+
+ let missing_entry = factory2_test_fxid_manager(&[]);
+ let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"absent", [0, 0, 0]);
+ let control = factory2_test_control_effects(vec![row], Some(0), Some(1));
+ assert!(
+ !factory2_selected_fx_resources_are_representable(&missing_entry, &control, &[4])
+ .expect("missing FX entry is not a decode error")
+ );
+
+ let row = factory2_test_effect_row(0, 0, b"absent.rlb", b"effect", [0, 0, 0]);
+ let control = factory2_test_control_effects(vec![row], Some(0), Some(1));
+ assert!(
+ !factory2_selected_fx_resources_are_representable(&manager, &control, &[4])
+ .expect("missing FX archive is unsupported input")
+ );
+
+ let wrong_type = factory2_test_fxid_manager(&[(b"effect", 0, &fxid)]);
+ let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]);
+ let control = factory2_test_control_effects(vec![row], Some(0), Some(1));
+ assert!(
+ !factory2_selected_fx_resources_are_representable(&wrong_type, &control, &[4])
+ .expect("wrong NRes type is unsupported, not malformed FXID")
+ );
+
+ for archive in [
+ [b'x'; 32],
+ {
+ let mut raw = [0; 32];
+ raw[0] = 0xff;
+ raw[1] = 0;
+ raw
+ },
+ factory2_test_effect_resource_pair(b"../effects.rlb", b"effect").archive,
+ ] {
+ let mut row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]);
+ row.resource.archive = archive;
+ let control = factory2_test_control_effects(vec![row], Some(0), Some(1));
+ assert!(
+ !factory2_selected_fx_resources_are_representable(&manager, &control, &[4])
+ .expect("invalid FX path or string remains unsupported")
+ );
+ }
+
+ let mut row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]);
+ row.resource.name = [b'x'; 32];
+ let control = factory2_test_control_effects(vec![row], Some(0), Some(1));
+ assert!(
+ !factory2_selected_fx_resources_are_representable(&manager, &control, &[4])
+ .expect("unterminated resource name is unsupported input")
+ );
+ }
+
+ #[test]
+ fn factory2_fx_guard_fails_on_malformed_recognized_fxid() {
+ let mut malformed_fxid = vec![0; 64];
+ malformed_fxid[..4].copy_from_slice(&1_u32.to_le_bytes());
+ malformed_fxid[60..64].copy_from_slice(&2_u32.to_le_bytes());
+ let manager = factory2_test_fxid_manager(&[(b"effect", FXID_KIND, &malformed_fxid)]);
+ let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]);
+ let control = factory2_test_control_effects(vec![row], Some(0), Some(1));
+
+ assert!(matches!(
+ factory2_selected_fx_resources_are_representable(&manager, &control, &[4]),
+ Err(AssetPreparationError::InvalidPrototype(_))
+ ));
+ }
+
+ #[test]
+ fn factory2_unsupported_ndp_numeric_projection_keeps_valid_mission_loaded() {
+ let control = factory2_test_control_payload(None, None);
+ let request = || MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ };
+ let unsupported_maxima = [
+ ("subnormal", vec![f32::from_bits(1)]),
+ ("nonfinite", vec![f32::NAN]),
+ ("ordered sum overflow", vec![f32::MAX, f32::MAX]),
+ ];
+
+ for (case, maxima) in unsupported_maxima {
+ let vfs = factory2_test_full_load_vfs_with_maxima(&control, &maxima);
+ let mut engine = create(EngineServices::new(vfs)).expect("factory-2 test engine");
+ load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .unwrap_or_else(|error| {
+ panic!("{case} resolver input aborted a structurally valid load: {error:?}")
+ });
+
+ let table = loaded_mission_control_device_tables(&engine)
+ .expect("loaded Control tables")
+ .first()
+ .expect("synthetic host Control table");
+ let devices = match &table.status {
+ MissionControlDeviceTableStatus::Available(devices) => devices,
+ MissionControlDeviceTableStatus::Unavailable(reason) => {
+ panic!("synthetic Control table unavailable for {case}: {reason:?}");
+ }
+ };
+ assert_eq!(devices.len(), 1, "{case}");
+ assert_eq!(
+ devices[0].device174_bits, None,
+ "unsupported {case} must stay unknown rather than become zero or NativeMiss"
+ );
+ assert_eq!(
+ query_loaded_mission_control_device_selector(&engine, table.handle, 0, 6),
+ Err(ControlResourceAccessError::UnsupportedField {
+ factory_tag: 2,
+ field: "+0x174",
+ }),
+ "unsupported {case} remains visible as an unavailable native value"
+ );
+ }
+ }
+
+ #[test]
+ fn factory2_resolver_accepts_only_known_initial_effect_opcodes() {
+ for opcode in [1, 12] {
+ let control = factory2_test_control_payload(None, Some(opcode));
+ let vfs = factory2_test_resolver_vfs(&control);
+ let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone()));
+ let pair = Factory2StringPair {
+ archive: "objects.rlb".to_string(),
+ name: "foreign".to_string(),
+ };
+
+ assert_eq!(
+ resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair)
+ .expect("complete known opcode group"),
+ Factory2PairOutcome::Value(0.0_f32.to_bits()),
+ "initial opcode {opcode} is covered"
+ );
+ }
+
+ let control = factory2_test_control_payload(None, Some(4));
+ let vfs = factory2_test_resolver_vfs(&control);
+ let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone()));
+ let pair = Factory2StringPair {
+ archive: "objects.rlb".to_string(),
+ name: "foreign".to_string(),
+ };
+ assert_eq!(
+ resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair)
+ .expect("opcode 4 remains representable as an unknown shape"),
+ Factory2PairOutcome::Unrepresentable
+ );
+ }
+
+ #[test]
+ fn factory2_resolver_keeps_unknown_and_nonbare_pairs_unrepresentable() {
+ let vfs = factory2_test_resolver_vfs(&factory2_test_control_payload(None, None));
+ let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone()));
+ for pair in [
+ Factory2StringPair {
+ archive: "static.rlb".to_string(),
+ name: "foreign".to_string(),
+ },
+ Factory2StringPair {
+ archive: "objects.rlb".to_string(),
+ name: "foreign.msh".to_string(),
+ },
+ Factory2StringPair {
+ archive: "objects.rlb".to_string(),
+ name: "nested/foreign".to_string(),
+ },
+ Factory2StringPair {
+ archive: "objects.rlb".to_string(),
+ name: "missing".to_string(),
+ },
+ ] {
+ assert_eq!(
+ resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair)
+ .expect("unknown mapping does not become a native miss"),
+ Factory2PairOutcome::Unrepresentable
+ );
+ }
+ }
+
+ #[test]
+ fn factory2_foreign_asset_error_keeps_loaded_world_and_rng() {
+ let valid_vfs = factory2_test_full_load_vfs_with_maxima(
+ &factory2_test_control_payload(None, None),
+ &[6.25],
+ );
+ let mut engine = create(EngineServices::new(valid_vfs)).expect("factory-2 test engine");
+ let request = || MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ };
+ load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("initial valid full load");
+ let previous_summary = loaded_mission(&engine).expect("loaded mission").clone();
+ assert_ne!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE);
+ let control_table =
+ &loaded_mission_control_device_tables(&engine).expect("Control table")[0];
+ let MissionControlDeviceTableStatus::Available(devices) = &control_table.status else {
+ panic!("synthetic host Control table should be available");
+ };
+ assert_eq!(devices.len(), 1);
+ assert_eq!(
+ devices[0].device174_bits,
+ Some(6.25_f32.to_bits()),
+ "full-load builder resolves the foreign pair lazily"
+ );
+ let handle = control_table.handle;
+ let retained_transform = TransformState {
+ position: [17.0_f32.to_bits(), 29.0_f32.to_bits(), 41.0_f32.to_bits()],
+ orientation: [0x3f00_0000; 3],
+ scale: [0x3f80_0000; 3],
+ };
+ set_transform(&mut engine.world, handle, retained_transform).expect("set sentinel pose");
+ let previous_rng = engine.behavior_rng_state;
+ engine.services.vfs = Some(factory2_test_full_load_vfs(&[0, 0, 0]));
+
+ let error = load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_200,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("malformed foreign CTLD aborts staged loading");
+
+ assert!(matches!(
+ error,
+ EngineError::AssetPreparation {
+ mission,
+ source: AssetPreparationError::InvalidPrototype(_),
+ } if mission == "MISSIONS/TEST/data.tma"
+ ));
+ assert_eq!(loaded_mission(&engine), Some(&previous_summary));
+ assert_eq!(engine.behavior_rng_state, previous_rng);
+ assert_eq!(
+ transform_state(&engine.world, handle),
+ Ok(retained_transform)
+ );
+ }
fn basement_draft(header_flags: Option<u32>, raw_after_name: u32) -> MissionObjectDraft {
MissionObjectDraft {
@@ -6680,6 +8416,21 @@ mod tests {
}
}
+ fn set_factory2_pair(record: &mut PreparedControlTypedRecord, archive: &[u8], name: &[u8]) {
+ fn write_words(words: &mut [u32], value: &[u8]) {
+ assert!(value.len() < words.len() * 4, "fixed name needs a NUL");
+ assert!(value.is_ascii(), "synthetic pair must be ASCII");
+ let mut bytes = [0; 32];
+ bytes[..value.len()].copy_from_slice(value);
+ for (word, bytes) in words.iter_mut().zip(bytes.chunks_exact(4)) {
+ *word = u32::from_le_bytes(bytes.try_into().expect("four-byte word"));
+ }
+ }
+
+ write_words(&mut record.fixed_words[27..35], archive);
+ write_words(&mut record.fixed_words[35..43], name);
+ }
+
fn synthetic_component_control(
index: usize,
typed_records: Vec<PreparedControlTypedRecord>,
@@ -6808,8 +8559,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
+ triangle_descriptors: None,
node_names: None,
animation: None,
}
@@ -7042,6 +8795,7 @@ mod tests {
multiplier_bits,
class9_selector0_scale_bits: class9_scale_bits,
tag26_extra_rate_bits: (factory_tag == 26).then_some(0),
+ device174_bits: (factory_tag == 2).then_some(0),
class9_values_bits: (factory_tag == 9).then_some([0x3f80_0000; 6]),
class9_deltas_bits: (factory_tag == 9).then_some([0; 6]),
native_word_50_bits,
@@ -7086,6 +8840,95 @@ mod tests {
}
}
+ #[test]
+ fn behavior_control_grouping_uses_current_tags_and_table_ordinals() {
+ let tags = [2, 30, 1, 30, 2, 99, 1, 2, 30, 2, 1];
+ let devices = tags
+ .into_iter()
+ .enumerate()
+ .map(|(ordinal, current_tag)| {
+ let mut device = synthetic_effective_control_device(
+ 5,
+ current_tag,
+ 1.0_f32.to_bits(),
+ None,
+ None,
+ );
+ device.key = 100 + ordinal as u32;
+ device.descriptor.fixed_words[1] = 200 + ordinal as u32;
+ device
+ })
+ .collect();
+ let table =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(devices));
+ let state = project_mission_behavior_control_grouping(&table);
+ assert_eq!(state.original_id, Some(OriginalObjectId(7)));
+ assert_eq!(
+ state.handle,
+ ObjectHandle {
+ generation: 5,
+ slot: 7
+ }
+ );
+ assert_eq!(
+ state.grouping,
+ Ok(MissionBehaviorControlGrouping {
+ ungrouped_candidate_device_ordinals: vec![0],
+ groups: vec![
+ MissionBehaviorControlGroup {
+ header_device_ordinal: 2,
+ candidate_device_ordinals: vec![4],
+ tag30_device_ordinals: vec![3],
+ },
+ MissionBehaviorControlGroup {
+ header_device_ordinal: 6,
+ candidate_device_ordinals: vec![7, 9],
+ tag30_device_ordinals: vec![8],
+ },
+ MissionBehaviorControlGroup {
+ header_device_ordinal: 10,
+ candidate_device_ordinals: Vec::new(),
+ tag30_device_ordinals: Vec::new(),
+ },
+ ],
+ }),
+ "group membership uses current descriptor tags and table ordinals, not factory tags or keys"
+ );
+
+ let empty =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new()));
+ assert_eq!(
+ project_mission_behavior_control_grouping(&empty).grouping,
+ Ok(MissionBehaviorControlGrouping::default())
+ );
+
+ let headerless_devices = [2, 30, 2]
+ .into_iter()
+ .map(|current_tag| {
+ synthetic_effective_control_device(5, current_tag, 1.0_f32.to_bits(), None, None)
+ })
+ .collect();
+ let headerless = synthetic_control_device_table(
+ MissionControlDeviceTableStatus::Available(headerless_devices),
+ );
+ assert_eq!(
+ project_mission_behavior_control_grouping(&headerless).grouping,
+ Ok(MissionBehaviorControlGrouping {
+ groups: Vec::new(),
+ ungrouped_candidate_device_ordinals: vec![0, 2],
+ })
+ );
+
+ let reason = MissionControlDeviceTableUnavailableReason::NoControlResource;
+ let unavailable = synthetic_control_device_table(
+ MissionControlDeviceTableStatus::Unavailable(reason.clone()),
+ );
+ assert_eq!(
+ project_mission_behavior_control_grouping(&unavailable).grouping,
+ Err(reason)
+ );
+ }
+
fn control_budget_test_table(generator_coefficient: f32) -> MissionControlDeviceTableState {
let mut generator = synthetic_effective_control_device(
19,
@@ -7986,6 +9829,251 @@ mod tests {
}
#[test]
+ fn control_resource_selector_15_uses_last_cached_factories_and_current_fields() {
+ let mut first_class9 = synthetic_effective_control_device(
+ 9,
+ 19,
+ 90.0_f32.to_bits(),
+ Some(f32::INFINITY.to_bits()),
+ Some(9.0_f32.to_bits()),
+ );
+ let mut first_class21 = synthetic_effective_control_device(
+ 21,
+ 19,
+ 90.0_f32.to_bits(),
+ Some(f32::INFINITY.to_bits()),
+ None,
+ );
+ let mut class9 = synthetic_effective_control_device(
+ 9,
+ 5,
+ 99.0_f32.to_bits(),
+ Some(f32::NAN.to_bits()),
+ Some(99.0_f32.to_bits()),
+ );
+ let mut class21 = synthetic_effective_control_device(
+ 21,
+ 26,
+ 90.0_f32.to_bits(),
+ Some(f32::INFINITY.to_bits()),
+ None,
+ );
+ first_class9.native_word_50_bits = Some(0);
+ first_class21.native_word_50_bits = Some(0);
+ class9.native_word_50_bits = Some(0);
+ class21.native_word_50_bits = Some(0);
+ class9.key = u32::MAX;
+ class21.key = u32::MAX;
+ class9.coefficient_bits.as_mut().unwrap()[0] = 3.0_f32.to_bits();
+ class9.class9_selector0_scale_bits = Some(4.0_f32.to_bits());
+ class21.coefficient_bits.as_mut().unwrap()[..6].copy_from_slice(&[
+ 1.0_f32.to_bits(),
+ 2.0_f32.to_bits(),
+ 3.0_f32.to_bits(),
+ 4.0_f32.to_bits(),
+ 5.0_f32.to_bits(),
+ 6.0_f32.to_bits(),
+ ]);
+
+ let table = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(vec![
+ first_class9,
+ first_class21,
+ class9,
+ class21,
+ ]),
+ MissionControlNodeProviderTableStatus::Unavailable(
+ MissionControlNodeProviderTableUnavailableReason::MissingNodeData {
+ unit_component_index: 0,
+ source: None,
+ },
+ ),
+ );
+
+ assert_eq!(
+ query_control_device_table_resource(&table, 15)
+ .expect("selector 15 uses current cached getter values")
+ .map(f32::to_bits),
+ Some(252.0_f32.to_bits())
+ );
+ }
+
+ #[test]
+ fn control_resource_selector_15_preserves_descending_binary32_accumulation() {
+ let mut class9 = synthetic_effective_control_device(
+ 9,
+ 19,
+ 1.0_f32.to_bits(),
+ None,
+ Some(1.0_f32.to_bits()),
+ );
+ let mut class21 = synthetic_effective_control_device(21, 19, 0, None, None);
+ class9.coefficient_bits.as_mut().unwrap()[0] = 1.0_f32.to_bits();
+ class21.coefficient_bits.as_mut().unwrap()[..6].copy_from_slice(&[
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ (-16_777_216.0_f32).to_bits(),
+ 16_777_216.0_f32.to_bits(),
+ 1.0_f32.to_bits(),
+ ]);
+ let forward = class21.coefficient_bits.unwrap()[..6]
+ .iter()
+ .map(|bits| f32::from_bits(*bits))
+ .fold(0.0_f32, |sum, value| sum + value);
+ assert_eq!(forward.to_bits(), 1.0_f32.to_bits());
+
+ let table =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9.clone(),
+ class21.clone(),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&table, 15)
+ .expect("descending native order")
+ .map(f32::to_bits),
+ Some(0.0_f32.to_bits())
+ );
+
+ class21.coefficient_bits.as_mut().unwrap()[..6].fill((-0.0_f32).to_bits());
+ let negative_zero =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9, class21,
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&negative_zero, 15)
+ .expect("negative zero is retained")
+ .map(f32::to_bits),
+ Some((-0.0_f32).to_bits())
+ );
+ }
+
+ #[test]
+ fn control_resource_selector_15_returns_no_result_without_both_cached_factories() {
+ let empty =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new()));
+ assert_eq!(query_control_device_table_resource(&empty, 15), Ok(None));
+
+ let class9_only =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ synthetic_effective_control_device(9, 35, 1, None, None),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&class9_only, 15),
+ Ok(None)
+ );
+
+ let mut class21 = synthetic_effective_control_device(21, 35, 1, None, None);
+ class21.coefficient_bits = None;
+ let class21_only =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class21,
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&class21_only, 15),
+ Ok(None)
+ );
+ }
+
+ #[test]
+ fn control_resource_selector_15_reports_missing_and_unsupported_inputs() {
+ let mut class9 = synthetic_effective_control_device(
+ 9,
+ 19,
+ 1.0_f32.to_bits(),
+ None,
+ Some(1.0_f32.to_bits()),
+ );
+ let mut class21 = synthetic_effective_control_device(21, 19, 1.0_f32.to_bits(), None, None);
+ class21.coefficient_bits = None;
+ let missing_coefficients =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9.clone(),
+ class21.clone(),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&missing_coefficients, 15),
+ Err(ControlResourceAccessError::UnsupportedField {
+ factory_tag: 21,
+ field: "coefficient table",
+ })
+ );
+
+ class21.coefficient_bits = Some([1.0_f32.to_bits(); 16]);
+ class9.coefficient_bits = None;
+ let missing_class9_coefficient =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9.clone(),
+ class21.clone(),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&missing_class9_coefficient, 15),
+ Err(ControlResourceAccessError::UnsupportedField {
+ factory_tag: 9,
+ field: "coefficient",
+ })
+ );
+
+ class9.coefficient_bits = Some([1.0_f32.to_bits(); 16]);
+ class9.class9_selector0_scale_bits = None;
+ let missing_scale =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9.clone(),
+ class21.clone(),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&missing_scale, 15),
+ Err(ControlResourceAccessError::UnsupportedField {
+ factory_tag: 9,
+ field: "class-9 selector-0 scale",
+ })
+ );
+
+ class9.class9_selector0_scale_bits = Some(1.0_f32.to_bits());
+ class21.coefficient_bits.as_mut().unwrap()[5] = f32::INFINITY.to_bits();
+ let nonfinite =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9.clone(),
+ class21.clone(),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&nonfinite, 15),
+ Err(ControlResourceAccessError::NonFiniteValue {
+ factory_tag: 21,
+ field: "coefficient",
+ })
+ );
+
+ class21.coefficient_bits.as_mut().unwrap()[5] = 1;
+ let subnormal =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9.clone(),
+ class21.clone(),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&subnormal, 15),
+ Err(ControlResourceAccessError::SubnormalValue {
+ factory_tag: 21,
+ field: "coefficient",
+ })
+ );
+
+ class21.coefficient_bits.as_mut().unwrap()[5] = f32::MAX.to_bits();
+ class21.coefficient_bits.as_mut().unwrap()[4] = f32::MAX.to_bits();
+ let overflowing_sum =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9, class21,
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&overflowing_sum, 15),
+ Err(ControlResourceAccessError::UnsupportedFloatRange {
+ factory_tag: Some(21),
+ field: "sum",
+ })
+ );
+ }
+
+ #[test]
fn control_resource_setter_updates_matching_rows_atomically_and_round_trips() {
let mut table =
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
@@ -9621,11 +11709,13 @@ mod tests {
trailing_bytes: Vec::new(),
raw: Arc::from(&b""[..]),
},
+ formula_table: None,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
};
let mut scripts = LoadedMissionScripts {
bundles: vec![bundle(0, 18, Vec::new()), bundle(1, 19, vec![0, 1, 2])],
+ ai_startup_states: Vec::new(),
varset: Some(varset),
..LoadedMissionScripts::default()
};
@@ -9637,7 +11727,7 @@ mod tests {
assert!(matches!(
error,
EngineError::ScriptInitUnsupported {
- source: Handler19ResolveError::UnsupportedSelector {
+ source: ScriptInitResolveError::UnsupportedSelector {
found: fparkan_script::ScriptDispatchSelector::Handler(18),
},
..
@@ -9655,6 +11745,193 @@ mod tests {
assert_eq!(rng_state, 0x0029_e2c0);
}
+ fn script_slots_test_mission(clans: &[(bool, bool, i32)]) -> MissionDocument {
+ fn push_u32(bytes: &mut Vec<u8>, value: u32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+ fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) {
+ push_u32(bytes, u32::try_from(value.len()).expect("length fits u32"));
+ bytes.extend_from_slice(value);
+ }
+
+ let mut tma = Vec::new();
+ push_u32(&mut tma, 1);
+ push_u32(&mut tma, 0);
+ push_u32(&mut tma, 6);
+ push_u32(
+ &mut tma,
+ u32::try_from(clans.len()).expect("clan count fits u32"),
+ );
+ for (clan_index, (spatial, scripted, tag)) in clans.iter().enumerate() {
+ push_lp(&mut tma, b"synthetic");
+ push_u32(&mut tma, u32::MAX);
+ tma.extend_from_slice(&(clan_index as f32).to_le_bytes());
+ tma.extend_from_slice(&0.0_f32.to_le_bytes());
+ let script_path: &[u8] = if *scripted {
+ b"MISSIONS/SCRIPTS/shared"
+ } else {
+ b""
+ };
+ if *spatial {
+ push_u32(&mut tma, 0);
+ push_lp(&mut tma, script_path);
+ push_u32(&mut tma, 0);
+ } else {
+ push_u32(&mut tma, 1);
+ push_lp(&mut tma, script_path);
+ push_u32(&mut tma, 0);
+ }
+ push_lp(&mut tma, b"unused.trf");
+ push_u32(&mut tma, *tag as u32);
+ push_u32(&mut tma, 0);
+ }
+ push_u32(&mut tma, 10);
+ push_u32(&mut tma, 0);
+ push_lp(&mut tma, b"");
+ push_u32(&mut tma, 0);
+ push_lp(&mut tma, b"");
+ push_u32(&mut tma, 1);
+ push_u32(&mut tma, 0);
+ decode_mission_payload(Arc::from(tma), TmaProfile::Strict).expect("synthetic mission TMA")
+ }
+
+ fn script_slots_test_tags(mission: &MissionDocument) -> Vec<i32> {
+ mission
+ .clans
+ .iter()
+ .map(|clan| match &clan.body {
+ ClanBody::Standard {
+ second_resource, ..
+ }
+ | ClanBody::Spatial {
+ second_resource, ..
+ } => second_resource.tag,
+ })
+ .collect()
+ }
+
+ fn script_slots_test_vfs() -> Arc<dyn Vfs> {
+ let package = encode_script_package(&ScriptPackage {
+ opcode_handler_count: 73,
+ events: Vec::new(),
+ trailing_bytes: Vec::new(),
+ raw: Arc::from(&b""[..]),
+ });
+ let mut files = MemoryVfs::default();
+ files.insert(
+ normalize_engine_path("script slots test", "MISSIONS/SCRIPTS/shared.scr")
+ .expect("script path"),
+ Arc::from(package.into_boxed_slice()),
+ );
+ files.insert(
+ normalize_engine_path("script slots test", "MISSIONS/SCRIPTS/shared.var")
+ .expect("varset path"),
+ Arc::from(&b"VAR(DWORD, value, 0)\n"[..]),
+ );
+ Arc::new(files)
+ }
+
+ #[test]
+ fn full_mission_script_loader_builds_independent_unsigned_ai_slots_per_clan() {
+ let mission = script_slots_test_mission(&[
+ (false, true, 2),
+ (true, true, 3),
+ (false, false, 4),
+ (false, true, 0),
+ ]);
+ let raw_tags = script_slots_test_tags(&mission);
+ let vfs = script_slots_test_vfs();
+
+ let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full)
+ .expect("Full script bundle load");
+ assert_eq!(
+ full.bundles
+ .iter()
+ .map(|bundle| bundle.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1, 3],
+ "empty-script clans do not produce fake script bundles"
+ );
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1, 2, 3],
+ "Full startup retains a state for every TMA clan"
+ );
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.record_slots.clone())
+ .collect::<Vec<_>>(),
+ [
+ vec![u32::MAX; 2],
+ vec![u32::MAX; 3],
+ vec![u32::MAX; 4],
+ Vec::new(),
+ ]
+ );
+ assert_ne!(
+ full.ai_startup_states[0].record_slots.as_ptr(),
+ full.ai_startup_states[1].record_slots.as_ptr(),
+ "each clan AI owns a separate vector"
+ );
+ assert_ne!(
+ full.ai_startup_states[1].record_slots.as_ptr(),
+ full.ai_startup_states[2].record_slots.as_ptr(),
+ "empty-script AI also owns a separate vector"
+ );
+ assert!(full
+ .bundles
+ .iter()
+ .all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing));
+
+ let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview)
+ .expect("preview script bundle load");
+ assert!(preview.ai_startup_states.is_empty());
+ assert_eq!(
+ preview
+ .bundles
+ .iter()
+ .map(|bundle| bundle.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1, 3]
+ );
+ assert_eq!(
+ script_slots_test_tags(&mission),
+ raw_tags,
+ "loading leaves the signed TMA tag bits unchanged"
+ );
+ }
+
+ #[test]
+ fn full_mission_script_loader_rejects_unsigned_and_cumulative_ai_slot_overflow() {
+ let vfs = script_slots_test_vfs();
+
+ let negative_tag = script_slots_test_mission(&[(false, false, -1)]);
+ assert!(matches!(
+ load_mission_scripts(&vfs, &negative_tag, MissionAssetScope::Full),
+ Err(EngineError::MissionAiRecordSlots {
+ clan_index: 0,
+ raw_count: u32::MAX,
+ ..
+ })
+ ));
+
+ let over_budget =
+ script_slots_test_mission(&[(false, true, 600_000), (true, false, 400_001)]);
+ assert!(matches!(
+ load_mission_scripts(&vfs, &over_budget, MissionAssetScope::Full),
+ Err(EngineError::MissionAiRecordSlots {
+ clan_index: 1,
+ raw_count: 400_001,
+ allocated_slots: 600_000,
+ ..
+ })
+ ));
+ }
+
#[test]
fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
let mut engine = create(EngineServices::default()).expect("engine");
@@ -9665,7 +11942,10 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect_err("missing VFS");
assert!(matches!(err, EngineError::MissingVfs));
@@ -10032,6 +12312,8 @@ mod tests {
[7.0_f32.to_bits(), 11.0_f32.to_bits(), 0, 0, 0];
draft.scale = [2.0, 3.0, 5.0];
+ let mut resolve_factory2_pair =
+ |_: &Factory2StringPair| Factory2PairOutcome::Unrepresentable;
let projection = build_control_device_projection_with_class(
0,
&draft,
@@ -10039,6 +12321,7 @@ mod tests {
Some(&(0..prototypes.len())),
&prototypes,
Some(native_class),
+ &mut resolve_factory2_pair,
)
.expect("base device and FAC0 projections");
let profile = projection
@@ -11266,6 +13549,71 @@ mod tests {
}
#[test]
+ fn indexed_provider_value_query_400_reads_current_value_by_device_key() {
+ let mut device = synthetic_effective_control_device(2, 99, 9.0_f32.to_bits(), None, None);
+ device.key = 1;
+ device.native_word_50_bits = Some(0);
+ device.coefficient_bits = None;
+ device.descriptor.fixed_words[8] = 13.0_f32.to_bits();
+ let table = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(vec![device]),
+ MissionControlNodeProviderTableStatus::Available(vec![
+ synthetic_node_provider_row(0, 0, 0.125_f32.to_bits()),
+ synthetic_node_provider_row(0, 1, 0.625_f32.to_bits()),
+ synthetic_node_provider_row(0, 2, 0.875_f32.to_bits()),
+ ]),
+ );
+
+ assert_eq!(
+ query_control_device_table_selector(&table, 0, 0x400),
+ Ok(Some(0.625_f32)),
+ "selector 0x400 returns the current type-1 value at the device's provider key"
+ );
+
+ let mut negative_zero = table.clone();
+ let MissionControlNodeProviderTableStatus::Available(rows) =
+ &mut negative_zero.node_provider_status
+ else {
+ unreachable!();
+ };
+ rows[1].current_type1_value_bits = (-0.0_f32).to_bits();
+ assert_eq!(
+ query_control_device_table_selector(&negative_zero, 0, 0x400)
+ .expect("negative-zero provider value")
+ .map(f32::to_bits),
+ Some((-0.0_f32).to_bits())
+ );
+
+ let reason = MissionControlNodeProviderTableUnavailableReason::MissingNodeData {
+ unit_component_index: 0,
+ source: None,
+ };
+ let unavailable = synthetic_control_device_table_with_providers(
+ table.status.clone(),
+ MissionControlNodeProviderTableStatus::Unavailable(reason.clone()),
+ );
+ assert_eq!(
+ query_control_device_table_selector(&unavailable, 0, 0x400),
+ Err(ControlResourceAccessError::NodeProviderTableUnavailable(
+ reason
+ ))
+ );
+
+ let mut out_of_range = table.clone();
+ let MissionControlDeviceTableStatus::Available(devices) = &mut out_of_range.status else {
+ unreachable!();
+ };
+ devices[0].key = 3;
+ assert_eq!(
+ query_control_device_table_selector(&out_of_range, 0, 0x400),
+ Err(ControlResourceAccessError::NodeProviderKeyOutOfRange {
+ key: 3,
+ row_count: 3,
+ })
+ );
+ }
+
+ #[test]
fn synthetic_into_builder_appends_all_rows_and_overrides_only_first_record() {
let append = synthetic_control_table(
&[None, Some(PROTOTYPE_TYPE_INTO)],
@@ -11511,7 +13859,10 @@ mod tests {
key: "MISSIONS/TEST/MINE/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect("full normal mission load");
assert_eq!(loaded.object_count, 1);
@@ -12207,6 +14558,392 @@ mod tests {
assert_eq!(rows[stage_key as usize].initial_amount_bits, stage_initial);
}
+ fn test_push_u32(bytes: &mut Vec<u8>, value: u32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn test_push_i32(bytes: &mut Vec<u8>, value: i32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn build_nres(entries: &[(u32, u32, u32, u32, Vec<u8>)]) -> Vec<u8> {
+ let mut bytes = vec![0; 16];
+ let mut offsets = Vec::with_capacity(entries.len());
+ for (_, _, _, _, payload) in entries {
+ offsets.push(u32::try_from(bytes.len()).expect("offset fits u32"));
+ bytes.extend_from_slice(payload);
+ let padding = (8 - bytes.len() % 8) % 8;
+ bytes.resize(bytes.len() + padding, 0);
+ }
+ for (index, (type_id, attr1, attr2, attr3, payload)) in entries.iter().enumerate() {
+ test_push_u32(&mut bytes, *type_id);
+ test_push_u32(&mut bytes, *attr1);
+ test_push_u32(&mut bytes, *attr2);
+ test_push_u32(
+ &mut bytes,
+ u32::try_from(payload.len()).expect("payload fits u32"),
+ );
+ test_push_u32(&mut bytes, *attr3);
+ let mut name = [0; 36];
+ name[..4].copy_from_slice(b"Test");
+ bytes.extend_from_slice(&name);
+ test_push_u32(&mut bytes, offsets[index]);
+ test_push_u32(&mut bytes, u32::try_from(index).expect("index fits u32"));
+ }
+ bytes[..4].copy_from_slice(b"NRes");
+ bytes[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
+ let count = u32::try_from(entries.len()).expect("count fits u32");
+ bytes[8..12].copy_from_slice(&count.to_le_bytes());
+ let size = u32::try_from(bytes.len()).expect("size fits u32");
+ bytes[12..16].copy_from_slice(&size.to_le_bytes());
+ bytes
+ }
+
+ fn empty_land_mesh() -> Vec<u8> {
+ build_nres(&[
+ (1, 0, 0, 38, Vec::new()),
+ (2, 0, 0, 0, vec![0; 0x8c]),
+ (3, 0, 0, 12, Vec::new()),
+ (4, 0, 0, 4, Vec::new()),
+ (5, 0, 0, 4, Vec::new()),
+ (18, 0, 0, 4, Vec::new()),
+ (14, 0, 0, 4, Vec::new()),
+ (11, 0, 0, 4, Vec::new()),
+ (21, 0, 0, 28, Vec::new()),
+ ])
+ }
+
+ fn minimal_land_map() -> Vec<u8> {
+ let mut payload = Vec::new();
+ for value in [0.0_f32, 0.0, 0.0, 0.0, 2.0, 0.0, 1.0, 0.0] {
+ payload.extend_from_slice(&value.to_le_bytes());
+ }
+ for value in [0, 0, 7, 0, 2, 0] {
+ test_push_u32(&mut payload, value);
+ }
+ for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0] {
+ payload.extend_from_slice(&value.to_le_bytes());
+ }
+ for _ in 0..2 {
+ test_push_i32(&mut payload, -1);
+ test_push_i32(&mut payload, -1);
+ }
+ test_push_u32(&mut payload, 1);
+ test_push_u32(&mut payload, 1);
+ payload.extend_from_slice(&1_u16.to_le_bytes());
+ payload.extend_from_slice(&0_u16.to_le_bytes());
+ build_nres(&[(12, 1, 0, 0, payload)])
+ }
+
+ #[test]
+ fn full_load_applies_a_synthetic_numeric_formula_in_init() {
+ fn push_u32(bytes: &mut Vec<u8>, value: u32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_i32(bytes: &mut Vec<u8>, value: i32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_f32(bytes: &mut Vec<u8>, value: f32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) {
+ push_u32(bytes, u32::try_from(value.len()).expect("length fits u32"));
+ bytes.extend_from_slice(value);
+ }
+
+ fn synthetic_mission() -> Vec<u8> {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, 6);
+ push_u32(&mut bytes, 1);
+ push_lp(&mut bytes, b"Synthetic");
+ push_u32(&mut bytes, u32::MAX);
+ push_f32(&mut bytes, 12.25);
+ push_f32(&mut bytes, 34.75);
+ push_u32(&mut bytes, 1);
+ push_lp(&mut bytes, b"MISSIONS/SCRIPTS/formula");
+ push_i32(&mut bytes, 0);
+ push_lp(&mut bytes, b"");
+ push_i32(&mut bytes, 0);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, 10);
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"DATA\\MAPS\\SYNTHETIC\\land");
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"");
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, 0);
+ bytes
+ }
+
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![fparkan_script::ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![
+ ScriptInstruction {
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
+ references: vec![0, 1, 2],
+ },
+ ScriptInstruction {
+ header_words: [u32::MAX, 3, u32::MAX, u32::MAX, 6, 0, 0],
+ references: Vec::new(),
+ },
+ ],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: Arc::from(&b""[..]),
+ };
+ let mut files = MemoryVfs::default();
+ for (path, bytes) in [
+ (
+ "MISSIONS/TEST/data.tma",
+ Arc::<[u8]>::from(synthetic_mission().into_boxed_slice()),
+ ),
+ (
+ "DATA/MAPS/SYNTHETIC/Land.msh",
+ Arc::<[u8]>::from(empty_land_mesh().into_boxed_slice()),
+ ),
+ (
+ "DATA/MAPS/SYNTHETIC/Land.map",
+ Arc::<[u8]>::from(minimal_land_map().into_boxed_slice()),
+ ),
+ ("BuildDat.lst", Arc::from(&b""[..])),
+ (
+ "MISSIONS/SCRIPTS/formula.scr",
+ Arc::<[u8]>::from(encode_script_package(&package).into_boxed_slice()),
+ ),
+ (
+ "MISSIONS/SCRIPTS/formula.var",
+ Arc::from(
+ &b"VAR(DWORD, ClanBaseX, 0)\nVAR(DWORD, ClanBaseY, 0)\nVAR(DWORD, ClanID, 99)\nVAR(float, target, 0.5)\n"[..],
+ ),
+ ),
+ (
+ "MISSIONS/SCRIPTS/formula.fml",
+ Arc::from(
+ &b"// authored Init expression\n\nFUNCTION( , ClanID + 0.8, )\nFUNCTION( later, target * 2, )\n"[..],
+ ),
+ ),
+ ] {
+ files.insert(
+ normalize_engine_path("synthetic mission", path).expect("valid path"),
+ bytes,
+ );
+ }
+ let vfs: Arc<dyn Vfs> = Arc::new(files);
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ let loaded = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
+ )
+ .expect("full load applies the authored numeric literal");
+
+ assert_eq!(loaded.script_bundle_count, 1);
+ let states = loaded_mission_script_varset_states(&engine).expect("runtime varset");
+ assert_eq!(
+ states[0].values,
+ [
+ VarSetDefault::Dword(12),
+ VarSetDefault::Dword(35),
+ VarSetDefault::Dword(0),
+ VarSetDefault::FloatBits(0x3f4c_cccd),
+ ]
+ );
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_fml_variable_rows_resolve_from_shared_varset_defaults() {
+ fn formula_action(target: u32, row: u32) -> ScriptInstruction {
+ ScriptInstruction {
+ header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, row],
+ references: Vec::new(),
+ }
+ }
+
+ fn init_event_index(package: &ScriptPackage) -> usize {
+ package
+ .events
+ .iter()
+ .position(|event| {
+ event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])
+ })
+ .expect("Init event")
+ }
+
+ fn row_is_assigned(instructions: &[ScriptInstruction], row: u32, target: u32) -> bool {
+ instructions.iter().any(|instruction| {
+ instruction.header_words[0] == u32::MAX
+ && instruction.header_words[1] == target
+ && instruction.header_words[2] == u32::MAX
+ && instruction.header_words[3] == u32::MAX
+ && instruction.header_words[6] == row
+ })
+ }
+
+ let root = licensed_root("IS");
+ let script_dir = root.join("MISSIONS/SCRIPTS");
+ let declarations = fparkan_script::parse_varset(
+ &std::fs::read(script_dir.join("varset.var")).expect("read shared varset"),
+ )
+ .expect("parse shared varset");
+ let formulas3 = FormulaTable::parse(
+ &std::fs::read(script_dir.join("c1m3e.fml")).expect("read c1m3e.fml"),
+ );
+ let package3 = fparkan_script::decode(
+ &std::fs::read(script_dir.join("c1m3e.scr")).expect("read c1m3e.scr"),
+ )
+ .expect("decode c1m3e.scr");
+ let init3_index = init_event_index(&package3);
+ let init3_event_word = package3.events[init3_index].event_word;
+ let init3 = &package3.events[init3_index].instructions;
+ assert!(row_is_assigned(init3, 0, 40));
+ assert!(row_is_assigned(init3, 1, 41));
+ assert!(row_is_assigned(init3, 2, 164));
+ assert!(!row_is_assigned(init3, 19, 158));
+ assert!(!row_is_assigned(init3, 22, 174));
+ assert!(row_is_assigned(
+ &package3
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next()
+ == Some(&b"Fort_Captured"[..]))
+ .expect("Fort_Captured event")
+ .instructions,
+ 19,
+ 158,
+ ));
+ assert!(row_is_assigned(
+ &package3
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next()
+ == Some(&b"PBM_BUILDING_PROTECT_Start"[..]))
+ .expect("building-protection event")
+ .instructions,
+ 22,
+ 174,
+ ));
+ assert_eq!(formulas3.rows[19].expression_raw, b" 0.8 + fT");
+ assert_eq!(
+ formulas3.rows[22].expression_raw,
+ b" dBuildingProtectHits - dT2"
+ );
+
+ let f_temp = u32::try_from(
+ declarations
+ .declarations
+ .iter()
+ .position(|declaration| declaration.name == "fTemp")
+ .expect("fTemp declaration"),
+ )
+ .expect("fTemp index fits u32");
+ let d_temp3 = u32::try_from(
+ declarations
+ .declarations
+ .iter()
+ .position(|declaration| declaration.name == "dTemp3")
+ .expect("dTemp3 declaration"),
+ )
+ .expect("dTemp3 index fits u32");
+ assert_eq!((f_temp, d_temp3), (158, 174));
+
+ let mut synthetic3 = package3.clone();
+ synthetic3.events[init3_index].instructions =
+ vec![formula_action(f_temp, 19), formula_action(d_temp3, 22)];
+ assert_eq!(
+ declarations.resolve_init(
+ &synthetic3,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas3),
+ ),
+ Ok(ScriptInitResolution::Executed {
+ event_index: init3_index,
+ event_word: init3_event_word,
+ writes: vec![
+ ScriptInitWrite {
+ index: f_temp,
+ value: VarSetDefault::FloatBits(0.8_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: d_temp3,
+ value: VarSetDefault::Dword(2_000_000),
+ },
+ ],
+ })
+ );
+
+ let formulas4 = FormulaTable::parse(
+ &std::fs::read(script_dir.join("c1m4e.fml")).expect("read c1m4e.fml"),
+ );
+ let package4 = fparkan_script::decode(
+ &std::fs::read(script_dir.join("c1m4e.scr")).expect("read c1m4e.scr"),
+ )
+ .expect("decode c1m4e.scr");
+ let init4_index = init_event_index(&package4);
+ let init4_event_word = package4.events[init4_index].event_word;
+ let init4 = &package4.events[init4_index].instructions;
+ assert!(!row_is_assigned(init4, 0, f_temp));
+ assert!(row_is_assigned(
+ &package4
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next()
+ == Some(&b"PBM_BUILDING_INF_CAPTURE_Start"[..]))
+ .expect("building-infantry-capture event")
+ .instructions,
+ 0,
+ f_temp,
+ ));
+ assert_eq!(formulas4.rows[0].expression_raw, b" fTemp + 0.00001");
+ let mut synthetic4 = package4.clone();
+ synthetic4.events[init4_index].instructions =
+ vec![formula_action(f_temp, 0), formula_action(f_temp, 0)];
+ assert_eq!(
+ declarations.resolve_init(
+ &synthetic4,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas4),
+ ),
+ Ok(ScriptInitResolution::Executed {
+ event_index: init4_index,
+ event_word: init4_event_word,
+ writes: vec![
+ ScriptInitWrite {
+ index: f_temp,
+ value: VarSetDefault::FloatBits(0.00001_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: f_temp,
+ value: VarSetDefault::FloatBits((0.00001_f32 + 0.00001_f32).to_bits()),
+ },
+ ],
+ })
+ );
+ }
+
#[test]
fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() {
let mut personal_files = MemoryVfs::default();
@@ -12257,6 +14994,27 @@ mod tests {
}
#[test]
+ fn script_formula_table_uses_an_optional_case_insensitive_scr_sibling() {
+ let mut files = MemoryVfs::default();
+ let vfs: Arc<dyn Vfs> = Arc::new(files.clone());
+ assert!(matches!(
+ load_script_formula_table(&vfs, "MISSIONS/SCRIPTS/custom.SCR"),
+ Ok(None)
+ ));
+
+ files.insert(
+ normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.fml").expect("path"),
+ Arc::from(&b"FUNCTION(value, 1.25, )\n"[..]),
+ );
+ let vfs: Arc<dyn Vfs> = Arc::new(files);
+ let formulas = load_script_formula_table(&vfs, "MISSIONS/SCRIPTS/custom.SCR")
+ .expect("load optional formula sibling")
+ .expect("formula file exists");
+ assert_eq!(formulas.rows.len(), 1);
+ assert_eq!(formulas.evaluate_numeric_literal(0), Ok(1.25));
+ }
+
+ #[test]
fn handler_nineteen_init_executes_from_clan_anchor_for_each_bundle() {
let declarations = fparkan_script::parse_varset(
b"VAR( DWORD, ClanBaseX, 950)\nVAR( DWORD, ClanBaseY, 1000)\nVAR( DWORD, ClanID, 0)\n",
@@ -12278,18 +15036,19 @@ mod tests {
name_raw: b"Init\0".to_vec(),
event_word: 0x1234_5678,
instructions: vec![fparkan_script::ScriptInstruction {
- header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
references: vec![0, 1, 2],
}],
}],
trailing_bytes: Vec::new(),
raw: Arc::from(&b""[..]),
},
+ formula_table: None,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
}];
- let states = resolve_handler19_init_states(
+ let states = resolve_init_states(
&[[500.0, 752.0], [728.0, 449.0]],
&mut bundles,
&varset,
@@ -12303,15 +15062,18 @@ mod tests {
event_index: 0,
event_word: 0x1234_5678,
writes: vec![
- Handler19DwordWrite {
+ ScriptInitWrite {
index: 0,
- value: 728
+ value: VarSetDefault::Dword(728),
},
- Handler19DwordWrite {
+ ScriptInitWrite {
index: 1,
- value: 449
+ value: VarSetDefault::Dword(449),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(1),
},
- Handler19DwordWrite { index: 2, value: 1 },
],
}]
);
@@ -12349,6 +15111,8 @@ mod tests {
#[test]
fn handler_nineteen_preview_keeps_unsupported_package_without_partial_writes() {
+ let formula_table =
+ FormulaTable::parse(b"FUNCTION(add, x + 2, )\nFUNCTION(unsupported, x * 2, )\n");
let varset = MissionScriptVarSet {
path: FALLBACK_SCRIPT_VARSET_PATH.to_string(),
used_fallback: true,
@@ -12365,11 +15129,15 @@ mod tests {
event_word: 0x1234,
instructions: vec![
fparkan_script::ScriptInstruction {
- header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
references: vec![0, 1, 2],
},
fparkan_script::ScriptInstruction {
- header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
+ header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 0],
+ references: Vec::new(),
+ },
+ fparkan_script::ScriptInstruction {
+ header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 1],
references: Vec::new(),
},
],
@@ -12382,12 +15150,13 @@ mod tests {
base_path: "MISSIONS/SCRIPTS/default".to_string(),
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
package: package.clone(),
+ formula_table: Some(formula_table.clone()),
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
};
let anchors = [[500.0, 752.0]];
let mut preview_bundles = vec![bundle()];
- let preview_states = resolve_handler19_init_states(
+ let preview_states = resolve_init_states(
&anchors,
&mut preview_bundles,
&varset,
@@ -12399,9 +15168,10 @@ mod tests {
preview_bundles[0].init_execution,
MissionScriptInitExecution::Unsupported {
event_index: 0,
- instruction_index: 1,
- error: Handler19ResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index: 2,
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression: b" x * 2".to_vec(),
},
}
);
@@ -12421,7 +15191,7 @@ mod tests {
let mut full_bundles = vec![bundle()];
assert!(matches!(
- resolve_handler19_init_states(
+ resolve_init_states(
&anchors,
&mut full_bundles,
&varset,
@@ -12430,12 +15200,13 @@ mod tests {
Err(EngineError::ScriptInitUnsupported {
clan_index: 0,
event_index: 0,
- instruction_index: 1,
- source: Handler19ResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index: 2,
+ source: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression,
},
..
- })
+ }) if expression == b" x * 2"
));
let mut malformed_package = package.clone();
@@ -12447,7 +15218,7 @@ mod tests {
..bundle()
}];
assert!(matches!(
- resolve_handler19_init_states(
+ resolve_init_states(
&anchors,
&mut malformed_bundle,
&varset,
@@ -12639,7 +15410,10 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::AutoDemo,
},
- 100_000,
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
)
.expect("load licensed AutoDemo");
@@ -12779,11 +15553,24 @@ mod tests {
assert_eq!(loaded.script_bundle_count, 2);
assert_eq!(loaded.script_init_state_count, 2);
assert_eq!(loaded.script_varset_state_count, 2);
- assert!(loaded_mission_script_bundles(&engine)
- .expect("script bundles")
- .iter()
- .all(|bundle| bundle.init_execution
- == MissionScriptInitExecution::Executed { event_index: 0 }));
+ let script_bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
+ assert_eq!(script_bundles.len(), 2);
+ assert!(script_bundles.iter().all(|bundle| bundle.init_execution
+ == MissionScriptInitExecution::Executed { event_index: 0 }));
+ for bundle in script_bundles {
+ let init = bundle
+ .package
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]))
+ .expect("first Init event");
+ let instruction = init.instructions.first().expect("Handler(19) action");
+ assert_eq!(
+ instruction.header_words,
+ [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX]
+ );
+ assert_eq!(instruction.references.as_slice(), &[224, 225, 226]);
+ }
assert_eq!(engine.behavior_rng_state, 0xc823_f683);
assert_eq!(
loaded_mission_script_bundles(&engine)
@@ -12837,7 +15624,10 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".into(),
mode: MissionMode::AutoDemo,
},
- 100_000,
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
)
.unwrap();
let tables = loaded_mission_control_device_tables(&engine).unwrap();
@@ -12858,6 +15648,334 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
+ fn full_load_builds_initial_behavior_control_groupings_for_autodemo() {
+ let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
+ licensed_root("IS"),
+ ))))
+ .expect("engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".into(),
+ mode: MissionMode::AutoDemo,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("load licensed AutoDemo with explicit startup inputs");
+
+ let groupings =
+ loaded_mission_behavior_control_groupings(&engine).expect("initial Behavior groupings");
+ let group = |header_device_ordinal, candidate_device_ordinals: &[usize]| {
+ MissionBehaviorControlGroup {
+ header_device_ordinal,
+ candidate_device_ordinals: candidate_device_ordinals.to_vec(),
+ tag30_device_ordinals: Vec::new(),
+ }
+ };
+ let expected = [
+ (0, vec![group(6, &[10, 11, 12, 13])]),
+ (1, vec![group(6, &[10, 11])]),
+ (2, vec![group(8, &[12, 13, 14])]),
+ (3, vec![group(6, &[10, 11, 12])]),
+ (4, Vec::new()),
+ (5, Vec::new()),
+ (6, Vec::new()),
+ (7, Vec::new()),
+ ];
+ assert_eq!(groupings.len(), expected.len());
+ for (original_id, expected_groups) in expected {
+ let state = groupings
+ .iter()
+ .find(|state| state.original_id == Some(OriginalObjectId(original_id)))
+ .expect("grouping for AutoDemo Control object");
+ assert_eq!(
+ state.grouping,
+ Ok(MissionBehaviorControlGrouping {
+ groups: expected_groups,
+ ungrouped_candidate_device_ordinals: Vec::new(),
+ }),
+ "original object {original_id}"
+ );
+ }
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn full_load_queries_control_selector_15_against_frozen_native_inputs() {
+ let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
+ licensed_root("IS"),
+ ))))
+ .expect("engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".into(),
+ mode: MissionMode::AutoDemo,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("load licensed AutoDemo with explicit startup inputs");
+
+ let tables = loaded_mission_control_device_tables(&engine).expect("Control tables");
+ let expected = [
+ (0, Some(0x4657_2800)),
+ (1, Some(0x4636_d000)),
+ (2, Some(0x467b_4001)),
+ (3, Some(0x468c_a000)),
+ (4, Some(0x46ec_4001)),
+ (5, Some(0x46ec_4001)),
+ (6, None),
+ (7, None),
+ ];
+ assert_eq!(tables.len(), expected.len());
+ for (original_id, expected_bits) in expected {
+ let table = tables
+ .iter()
+ .find(|table| table.original_id == Some(OriginalObjectId(original_id)))
+ .expect("AutoDemo Control table for original object");
+ let actual = query_loaded_mission_control_resource(&engine, table.handle, 15)
+ .expect("selector 15 query")
+ .map(f32::to_bits);
+ assert_eq!(actual, expected_bits, "original object {original_id}");
+ }
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn full_load_queries_indexed_control_selector_400_for_all_autodemo_devices() {
+ let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
+ licensed_root("IS"),
+ ))))
+ .expect("engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".into(),
+ mode: MissionMode::AutoDemo,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("load licensed AutoDemo with explicit startup inputs");
+
+ let tables = loaded_mission_control_device_tables(&engine).expect("Control tables");
+ let expected: [(u32, usize, &[u32]); 8] = [
+ (0, 33, &[0, 0, 0, 0, 0, 0, 16, 19, 16, 18, 27, 30, 31, 32]),
+ (1, 23, &[0, 0, 0, 0, 0, 0, 10, 13, 10, 11, 19, 22]),
+ (2, 26, &[0, 0, 0, 0, 0, 0, 0, 0, 8, 9, 8, 10, 19, 22, 25]),
+ (3, 29, &[0, 0, 0, 0, 0, 0, 16, 21, 16, 20, 26, 27, 28]),
+ (4, 32, &[5, 5, 1, 23, 3, 22, 7, 19, 16, 23, 5, 29]),
+ (5, 32, &[5, 5, 1, 23, 3, 22, 7, 19, 16, 23, 5, 29]),
+ (6, 22, &[17, 19, 19, 15, 13, 14, 10, 11, 4, 5, 7]),
+ (7, 22, &[17, 19, 19, 15, 13, 14, 10, 11, 4, 5, 7]),
+ ];
+ assert_eq!(tables.len(), expected.len());
+
+ let mut queried_devices = 0;
+ for (original_id, expected_provider_rows, expected_keys) in expected {
+ let table = tables
+ .iter()
+ .find(|table| table.original_id == Some(OriginalObjectId(original_id)))
+ .expect("AutoDemo Control table for original object");
+ let devices = match &table.status {
+ MissionControlDeviceTableStatus::Available(devices) => devices,
+ MissionControlDeviceTableStatus::Unavailable(reason) => {
+ panic!("Control table for object {original_id} is unavailable: {reason:?}");
+ }
+ };
+ let provider_rows = match &table.node_provider_status {
+ MissionControlNodeProviderTableStatus::Available(rows) => rows,
+ MissionControlNodeProviderTableStatus::Unavailable(reason) => {
+ panic!("provider rows for object {original_id} are unavailable: {reason:?}");
+ }
+ };
+ assert_eq!(
+ devices.len(),
+ expected_keys.len(),
+ "device count for {original_id}"
+ );
+ assert_eq!(
+ provider_rows.len(),
+ expected_provider_rows,
+ "provider row count for {original_id}"
+ );
+
+ for (device_index, expected_key) in expected_keys.iter().copied().enumerate() {
+ let device = &devices[device_index];
+ assert_eq!(
+ device.key, expected_key,
+ "provider key for object {original_id}, device {device_index}"
+ );
+ assert!(
+ (expected_key as usize) < provider_rows.len(),
+ "provider key for object {original_id}, device {device_index}"
+ );
+ let actual = query_loaded_mission_control_device_selector(
+ &engine,
+ table.handle,
+ device_index,
+ 0x400,
+ )
+ .expect("selector 0x400 query");
+ assert_eq!(
+ actual.map(f32::to_bits),
+ Some(0x3f80_0000),
+ "current provider value for object {original_id}, device {device_index}, key {expected_key}"
+ );
+ queried_devices += 1;
+ }
+ }
+ assert_eq!(queried_devices, 100);
+ }
+
+ #[test]
+ #[ignore = "requires licensed AutoDemo corpus"]
+ fn licensed_autodemo_queries_selector_6_for_all_effective_factory2_pairs() {
+ let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
+ licensed_root("IS"),
+ ))))
+ .expect("engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".into(),
+ mode: MissionMode::AutoDemo,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .unwrap_or_else(|error| {
+ panic!("full AutoDemo load failed before selector-6 queries: {error:?}")
+ });
+
+ let expected_vectors = [
+ ("bb_m_01", 0x43f9_8000),
+ ("bm_m_01", 0x4422_8000),
+ ("bl_l_01", 0x436a_0000),
+ ("br_b_01", 0x44c8_0000),
+ ("bf_b_01", 0x443b_8000),
+ ("bl_m_01", 0x443b_4000),
+ ("bm_l_01", 0x4334_0000),
+ ];
+ let tables = loaded_mission_control_device_tables(&engine).expect("Control tables");
+ let mut observed = Vec::new();
+ for table in tables {
+ let devices = match &table.status {
+ MissionControlDeviceTableStatus::Available(devices) => devices,
+ MissionControlDeviceTableStatus::Unavailable(reason) => {
+ panic!(
+ "Control table for object {:?} is unavailable before selector-6 census: {reason:?}",
+ table.original_id
+ );
+ }
+ };
+ for (device_index, device) in devices.iter().enumerate() {
+ if device.descriptor.tag != 2 || device.factory_tag != 2 {
+ continue;
+ }
+ let pair = factory2_pair_from_record(&device.descriptor);
+ let result = query_loaded_mission_control_device_selector(
+ &engine,
+ table.handle,
+ device_index,
+ 6,
+ );
+ observed.push((table.original_id, device_index, pair, result));
+ }
+ }
+
+ let mut observed_pairs = Vec::new();
+ let mut mismatches = Vec::new();
+ for (original_id, device_index, pair, result) in &observed {
+ let pair_label = pair
+ .as_ref()
+ .map(|pair| format!("{}:{}", pair.archive, pair.name))
+ .unwrap_or_else(|| "<unparsed>".to_string());
+ match result {
+ Ok(Some(value)) => println!(
+ "selector6 object={original_id:?} device={device_index} pair={pair_label} result=Some({:08x})",
+ value.to_bits()
+ ),
+ Ok(None) => println!(
+ "selector6 object={original_id:?} device={device_index} pair={pair_label} result=None"
+ ),
+ Err(error) => println!(
+ "selector6 object={original_id:?} device={device_index} pair={pair_label} error={error:?}"
+ ),
+ }
+
+ let Some(pair) = pair else {
+ mismatches.push(format!(
+ "object {original_id:?} device {device_index}: malformed factory-2 pair"
+ ));
+ continue;
+ };
+ if !pair.archive.eq_ignore_ascii_case("objects.rlb") {
+ mismatches.push(format!(
+ "object {original_id:?} device {device_index}: unexpected archive {}",
+ pair.archive
+ ));
+ continue;
+ }
+ let name = pair.name.to_ascii_lowercase();
+ if !observed_pairs.contains(&name) {
+ observed_pairs.push(name.clone());
+ }
+ let expected = expected_vectors
+ .iter()
+ .find(|(expected_name, _)| name.as_str() == *expected_name)
+ .map(|(_, bits)| *bits);
+ match (result, expected) {
+ (Ok(Some(value)), Some(expected_bits)) if value.to_bits() == expected_bits => {}
+ (Ok(Some(value)), Some(expected_bits)) => mismatches.push(format!(
+ "object {original_id:?} device {device_index} pair {name}: expected {expected_bits:08x}, got {:08x}",
+ value.to_bits()
+ )),
+ (Ok(None), Some(_)) => mismatches.push(format!(
+ "object {original_id:?} device {device_index} pair {name}: native selector returned None"
+ )),
+ (Err(error), Some(_)) => mismatches.push(format!(
+ "object {original_id:?} device {device_index} pair {name}: {error:?}"
+ )),
+ (_, None) => mismatches.push(format!(
+ "object {original_id:?} device {device_index}: pair {name} is not in the frozen native vectors"
+ )),
+ }
+ }
+
+ observed_pairs.sort();
+ let mut expected_pairs = expected_vectors
+ .iter()
+ .map(|(name, _)| (*name).to_string())
+ .collect::<Vec<_>>();
+ expected_pairs.sort();
+ println!(
+ "selector6 census: effective_devices={}, distinct_pairs={}, pairs={observed_pairs:?}",
+ observed.len(),
+ observed_pairs.len()
+ );
+ assert_eq!(
+ observed_pairs, expected_pairs,
+ "actual effective AutoDemo factory-2 pair census differs from the frozen vector set"
+ );
+ assert!(
+ mismatches.is_empty(),
+ "selector-6 mismatches: {mismatches:#?}"
+ );
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
fn headless_step_runs_resource_schedule_and_rolls_back_both_error_paths() {
let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
licensed_root("IS"),
@@ -12869,7 +15987,10 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".into(),
mode: MissionMode::AutoDemo,
},
- 100_000,
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
)
.expect("licensed mission");
let schedules = |engine: &Engine| {
@@ -12978,7 +16099,10 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::AutoDemo,
},
- 100_000,
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
)
.expect("load licensed AutoDemo");
@@ -13713,16 +16837,17 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
- fn licensed_campaign_preview_keeps_unsupported_init_without_partial_writes() {
+ fn licensed_campaign_full_load_applies_literals_then_single_handler43_rolls_back() {
let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
- let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ let mut engine = create(EngineServices::new(vfs.clone())).expect("engine");
+ let campaign_request = || MissionRequest {
+ key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ };
let loaded = load_mission_static_preview_roots(
&mut engine,
- MissionRequest {
- key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
- mode: MissionMode::SingleMission,
- },
+ campaign_request(),
NonZeroUsize::new(1).expect("non-zero root count"),
)
.expect("load licensed campaign preview");
@@ -13741,32 +16866,310 @@ mod tests {
.iter()
.all(|bundle| bundle.behavior_warm_start.is_none()));
assert_eq!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE);
- let unsupported = bundles
+ let bundle = bundles
.iter()
- .find(|bundle| {
- matches!(
- &bundle.init_execution,
- MissionScriptInitExecution::Unsupported { .. }
- )
- })
- .expect("campaign package with an unsupported Init instruction");
- let clan_index = unsupported.clan_index;
+ .find(|bundle| bundle.clan_index == 0)
+ .expect("first campaign script bundle");
+ assert_eq!(
+ bundle.base_path.replace('\\', "/"),
+ "MISSIONS/SCRIPTS/tut1_pl2"
+ );
+ assert_eq!(bundle.formula_table.as_ref().unwrap().rows.len(), 26);
+ assert_eq!(
+ bundle
+ .formula_table
+ .as_ref()
+ .unwrap()
+ .evaluate_numeric_literal(0),
+ Ok(0.0)
+ );
+ assert_eq!(
+ bundle.init_execution,
+ MissionScriptInitExecution::Executed { event_index: 0 }
+ );
let states = loaded_mission_script_init_states(&engine).expect("Init writes");
- assert!(states.iter().all(|state| state.clan_index != clan_index));
+ let init = states
+ .iter()
+ .find(|state| state.clan_index == 0)
+ .expect("first clan Init writes");
+ assert_eq!(
+ init.writes
+ .iter()
+ .map(|write| write.index)
+ .collect::<Vec<_>>(),
+ [224, 225, 226, 188, 189, 190, 191]
+ );
+ assert!(init.writes[3..]
+ .iter()
+ .all(|write| write.value == VarSetDefault::Dword(0)));
let varset = loaded_mission_script_varset_states(&engine)
.expect("per-clan varsets")
.iter()
- .find(|state| state.clan_index == clan_index)
- .expect("unsupported clan varset");
+ .find(|state| state.clan_index == 0)
+ .expect("first clan varset");
+ assert_eq!(
+ &varset.values[188..192],
+ &[
+ VarSetDefault::Dword(0),
+ VarSetDefault::Dword(0),
+ VarSetDefault::Dword(0),
+ VarSetDefault::Dword(0),
+ ]
+ );
assert_eq!(
&varset.values[224..227],
&[
- VarSetDefault::Dword(950),
- VarSetDefault::Dword(1000),
+ VarSetDefault::Dword(549),
+ VarSetDefault::Dword(630),
VarSetDefault::Dword(0),
]
);
assert!(loaded.script_bundle_count >= 1);
+
+ let full = load_mission(
+ &mut engine,
+ campaign_request(),
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("full load licensed campaign mission");
+ assert_eq!(full.script_bundle_count, 4);
+ assert_eq!(full.script_init_state_count, 4);
+ assert_eq!(full.script_varset_state_count, 4);
+ let full_bundles = loaded_mission_script_bundles(&engine).expect("full script bundles");
+ assert_eq!(full_bundles.len(), 4);
+ assert!(full_bundles.iter().all(|bundle| matches!(
+ &bundle.init_execution,
+ MissionScriptInitExecution::Executed { event_index: 0 }
+ )));
+ assert!(full_bundles
+ .iter()
+ .all(|bundle| bundle.behavior_warm_start.is_some()));
+ let full_states = loaded_mission_script_init_states(&engine).expect("full Init writes");
+ let first_full_init = full_states
+ .iter()
+ .find(|state| state.clan_index == 0)
+ .expect("campaign clan 0 Init");
+ assert_eq!(
+ first_full_init
+ .writes
+ .iter()
+ .map(|write| write.index)
+ .collect::<Vec<_>>(),
+ [224, 225, 226, 188, 189, 190, 191]
+ );
+ assert!(first_full_init.writes[3..]
+ .iter()
+ .all(|write| write.value == VarSetDefault::Dword(0)));
+ let campaign_summary = loaded_mission(&engine).expect("full campaign").clone();
+ let campaign_bundles = full_bundles.to_vec();
+ let campaign_init_states = full_states.to_vec();
+ let campaign_varsets = loaded_mission_script_varset_states(&engine)
+ .expect("full per-clan varsets")
+ .to_vec();
+ let campaign_rng = engine.behavior_rng_state;
+ let before = step_headless(&mut engine, InputSnapshot, 0)
+ .expect("step loaded campaign")
+ .snapshot
+ .objects;
+
+ let single_error = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Single.01/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_500,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("Single.01 reaches the later Handler(43) boundary");
+ assert!(matches!(
+ single_error,
+ EngineError::ScriptInitUnsupported {
+ clan_index: 1,
+ path,
+ event_index: 0,
+ instruction_index: 10,
+ source: ScriptInitResolveError::UnsupportedSelector {
+ found: fparkan_script::ScriptDispatchSelector::Handler(43),
+ },
+ } if path.ends_with("scream.scr")
+ ));
+ assert_eq!(loaded_mission(&engine), Some(&campaign_summary));
+ assert_eq!(
+ loaded_mission_script_bundles(&engine),
+ Some(campaign_bundles.as_slice())
+ );
+ assert_eq!(
+ loaded_mission_script_init_states(&engine),
+ Some(campaign_init_states.as_slice())
+ );
+ assert_eq!(
+ loaded_mission_script_varset_states(&engine),
+ Some(campaign_varsets.as_slice())
+ );
+ assert_eq!(engine.behavior_rng_state, campaign_rng);
+ let after = step_headless(&mut engine, InputSnapshot, 0)
+ .expect("step retained campaign")
+ .snapshot
+ .objects;
+ assert_eq!(before, after);
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_single_script_bundle_load_initializes_ai_slots_before_later_init_work() {
+ let root = licensed_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let path = normalize_engine_path("mission", "MISSIONS/Single.01/data.tma")
+ .expect("Single.01 path");
+ let mission = decode_mission_payload(
+ read_vfs(&vfs, &path).expect("Single.01 TMA"),
+ TmaProfile::Strict,
+ )
+ .expect("decode Single.01 TMA");
+ assert_eq!(script_slots_test_tags(&mission), [9, 9]);
+
+ let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full)
+ .expect("Full script bundle load");
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.record_slots.len())
+ .collect::<Vec<_>>(),
+ [9, 9]
+ );
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1]
+ );
+ assert!(full
+ .ai_startup_states
+ .iter()
+ .all(|state| { state.record_slots == [u32::MAX; 9] }));
+ assert!(full
+ .bundles
+ .iter()
+ .all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing));
+
+ let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview)
+ .expect("preview script bundle load");
+ assert!(preview.ai_startup_states.is_empty());
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_single_preview_loads_each_formula_table_and_stops_at_handler43() {
+ let root = licensed_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ load_mission_static_preview_roots(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Single.01/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ NonZeroUsize::new(1).expect("non-zero root count"),
+ )
+ .expect("load licensed Single.01 preview");
+
+ assert!(loaded_mission_behavior_control_groupings(&engine)
+ .expect("preview grouping projection")
+ .is_empty());
+
+ let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
+ assert_eq!(bundles.len(), 2);
+ let first = bundles
+ .iter()
+ .find(|bundle| bundle.clan_index == 0)
+ .expect("scr_pl_1 bundle");
+ assert_eq!(
+ first.base_path.replace('\\', "/"),
+ "MISSIONS/SCRIPTS/scr_pl_1"
+ );
+ assert_eq!(first.formula_table.as_ref().unwrap().rows.len(), 5);
+ assert_eq!(
+ first.init_execution,
+ MissionScriptInitExecution::Executed { event_index: 0 }
+ );
+ let second = bundles
+ .iter()
+ .find(|bundle| bundle.clan_index == 1)
+ .expect("scream bundle");
+ assert_eq!(
+ second.base_path.replace('\\', "/"),
+ "MISSIONS/SCRIPTS/scream"
+ );
+ let formulas = second.formula_table.as_ref().expect("scream FML");
+ assert_eq!(formulas.rows.len(), 149);
+ for (index, expected_bits) in [
+ 0x3ecc_cccd,
+ 0x3f4c_cccd,
+ 0x3f00_0000,
+ 0x3f80_0000,
+ 0x4120_0000,
+ 0x3e4c_cccd,
+ 0x42c8_0000,
+ 0x43c8_0000,
+ 0x4000_0000,
+ ]
+ .into_iter()
+ .enumerate()
+ {
+ assert_eq!(
+ formulas
+ .evaluate_numeric_literal(index as u32)
+ .expect("referenced native literal")
+ .to_bits(),
+ expected_bits
+ );
+ }
+ assert_eq!(
+ second.init_execution,
+ MissionScriptInitExecution::Unsupported {
+ event_index: 0,
+ instruction_index: 10,
+ error: ScriptInitResolveError::UnsupportedSelector {
+ found: fparkan_script::ScriptDispatchSelector::Handler(43),
+ },
+ }
+ );
+
+ let states = loaded_mission_script_init_states(&engine).expect("preview Init writes");
+ assert!(states.iter().any(|state| state.clan_index == 0));
+ assert!(states.iter().all(|state| state.clan_index != 1));
+ let first_varset = loaded_mission_script_varset_states(&engine)
+ .expect("per-clan varsets")
+ .iter()
+ .find(|state| state.clan_index == 0)
+ .expect("scr_pl_1 varset");
+ assert_eq!(
+ &first_varset.values[186..188],
+ &[VarSetDefault::Dword(0), VarSetDefault::Dword(0)]
+ );
+ let declarations = loaded_mission_script_varset(&engine)
+ .expect("shared script varset")
+ .declarations
+ .declarations
+ .iter()
+ .map(|declaration| declaration.default_value)
+ .collect::<Vec<_>>();
+ let second_varset = loaded_mission_script_varset_states(&engine)
+ .expect("per-clan varsets")
+ .iter()
+ .find(|state| state.clan_index == 1)
+ .expect("scream varset");
+ assert_eq!(second_varset.values, declarations);
+ assert!(bundles
+ .iter()
+ .all(|bundle| bundle.behavior_warm_start.is_none()));
}
#[test]
@@ -13800,8 +17203,15 @@ mod tests {
.snapshot
.objects;
- let error = load_mission(&mut engine, request(), 0)
- .expect_err("malformed profile archive prevents full mission load");
+ let error = load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("malformed profile archive prevents full mission load");
assert!(matches!(
error,
EngineError::Nres { ref path, .. } if path == "behpsp.res"
@@ -13930,7 +17340,10 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect("load mission");
@@ -14003,7 +17416,15 @@ mod tests {
});
let mut engine = create(EngineServices::new(vfs)).expect("engine");
let before = step_headless(&mut engine, InputSnapshot, 0).expect("before");
- let err = load_mission(&mut engine, mission, 0).expect_err("load error");
+ let err = load_mission(
+ &mut engine,
+ mission,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("load error");
match denied {
DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })),
DenyRule::Suffix("objects.rlb") => {
@@ -14035,7 +17456,10 @@ mod tests {
fail_after_registered_objects: Some(1),
..MissionLoadOptions::default()
},
- Some(0),
+ Some(MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ }),
)
.expect_err("forced registration failure");
@@ -14064,16 +17488,31 @@ mod tests {
.iter()
.position(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]))
.expect("Init event");
- assert_eq!(
- package.events[init_index].instructions[0].header_words[0],
- 19
+ let init = &mut package.events[init_index];
+ let handler = init
+ .instructions
+ .first()
+ .expect("original Handler(19) instruction");
+ assert_eq!(handler.header_words[0], 19);
+ assert_eq!(handler.references.as_slice(), &[224, 225, 226]);
+ let original_instruction_count = init.instructions.len();
+ init.instructions.push(ScriptInstruction {
+ header_words: [u32::MAX, 164, u32::MAX, u32::MAX, 6, 0, 0],
+ references: Vec::new(),
+ });
+ init.instructions.push(ScriptInstruction {
+ header_words: [u32::MAX, 158, u32::MAX, u32::MAX, 6, 0, 1],
+ references: Vec::new(),
+ });
+ let replacement_script = Arc::<[u8]>::from(encode_script_package(&package));
+ let replacement_formula = Arc::<[u8]>::from(
+ &b"FUNCTION(add, ClanBaseX + 0.8, )\nFUNCTION(unsupported, fT * 2, )\n"[..],
);
- package.events[init_index].instructions[0].header_words[0] = 18;
- let replacement = Arc::<[u8]>::from(encode_script_package(&package));
let vfs = Arc::new(SwappingScriptVfs {
inner: DirectoryVfs::new(&root),
- replacement,
+ replacement_script,
+ replacement_formula,
enabled: std::sync::atomic::AtomicBool::new(false),
});
let services_vfs: Arc<dyn Vfs> = vfs.clone();
@@ -14082,7 +17521,15 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::AutoDemo,
};
- load_mission(&mut engine, mission(), 100_000).expect("initial full startup");
+ load_mission(
+ &mut engine,
+ mission(),
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("initial full startup");
let previous_loaded = loaded_mission(&engine).expect("previous mission").clone();
let previous_bundles = loaded_mission_script_bundles(&engine)
@@ -14098,16 +17545,25 @@ mod tests {
.objects;
vfs.enabled.store(true, std::sync::atomic::Ordering::SeqCst);
- let error = load_mission(&mut engine, mission(), 100_500)
- .expect_err("unsupported Init must fail after new-world registration");
+ let error = load_mission(
+ &mut engine,
+ mission(),
+ MissionStartupInput {
+ queue_time_ms: 100_500,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("unsupported Init must fail after new-world registration");
assert!(matches!(
error,
EngineError::ScriptInitUnsupported {
- source: Handler19ResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index,
+ source: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression,
},
..
- }
+ } if instruction_index == original_instruction_count + 1 && expression == b" fT * 2"
));
assert_eq!(loaded_mission(&engine), Some(&previous_loaded));
assert_eq!(
@@ -14176,7 +17632,10 @@ mod tests {
key: case.1.to_string(),
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect("load mission");
let drafts = loaded_mission_object_drafts(&engine).expect("object drafts");
@@ -14290,7 +17749,10 @@ mod tests {
key: case.mission.to_string(),
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect("load selected mission");
assert_eq!(loaded.object_count, case.object_count);
@@ -14320,7 +17782,10 @@ mod tests {
key: mission,
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect("load mission foundation");
assert_eq!(loaded.object_count, loaded.registered_objects);
@@ -14460,6 +17925,516 @@ mod tests {
bytes
}
+ fn research_test_trf() -> Vec<u8> {
+ let mut row = [0_u32; 10];
+ row[0] = 0x1122_3344;
+ row[1] = 0x5566_7788;
+ row[8] = 0x09ff_0000;
+ let mut row_bytes = Vec::with_capacity(40);
+ for word in row {
+ row_bytes.extend_from_slice(&word.to_le_bytes());
+ }
+ let mut alias = Vec::new();
+ alias.extend_from_slice(&0_u16.to_le_bytes());
+ alias.extend_from_slice(&0_u16.to_le_bytes());
+ let zero_group = 0_u32.to_le_bytes().to_vec();
+ build_nres(&[
+ (u32::from_le_bytes(*b"TRF0"), 1, 3, 0, row_bytes),
+ (u32::from_le_bytes(*b"TRF1"), 1, 0, 0, vec![0x5a]),
+ (u32::from_le_bytes(*b"TRF2"), 1, 0, 0, zero_group.clone()),
+ (u32::from_le_bytes(*b"TRF4"), 1, 0, 0, zero_group),
+ (u32::from_le_bytes(*b"TRF6"), 0, 0, 0, b"test\0".to_vec()),
+ (u32::from_le_bytes(*b"TRF7"), 0, 0, 0, Vec::new()),
+ (u32::from_le_bytes(*b"TRF8"), 0, 0, 0, Vec::new()),
+ (u32::from_le_bytes(*b"TRF9"), 0, 0, 0, Vec::new()),
+ (u32::from_le_bytes(*b"TRFA"), 0, 0, 0, Vec::new()),
+ (u32::from_le_bytes(*b"TRFB"), 1, 0, 0, alias),
+ ])
+ }
+
+ fn research_startup_test_vfs(
+ script_bases: &[Option<&[u8]>],
+ research_paths: &[&[u8]],
+ trf: Option<&[u8]>,
+ ) -> Arc<dyn Vfs> {
+ research_startup_test_vfs_with_raw_tag(script_bases, research_paths, 9, trf)
+ }
+
+ fn research_startup_test_vfs_with_raw_tag(
+ script_bases: &[Option<&[u8]>],
+ research_paths: &[&[u8]],
+ raw_tag: i32,
+ trf: Option<&[u8]>,
+ ) -> Arc<dyn Vfs> {
+ fn push_u32(bytes: &mut Vec<u8>, value: u32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+ fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) {
+ push_u32(
+ bytes,
+ u32::try_from(value.len()).expect("string length fits u32"),
+ );
+ bytes.extend_from_slice(value);
+ }
+ assert_eq!(script_bases.len(), research_paths.len());
+ let mut mission = Vec::new();
+ push_u32(&mut mission, 1);
+ push_u32(&mut mission, 0);
+ push_u32(&mut mission, 6);
+ push_u32(&mut mission, script_bases.len() as u32);
+ for (index, (script_base, research_path)) in
+ script_bases.iter().zip(research_paths).enumerate()
+ {
+ push_lp(&mut mission, b"Synthetic clan");
+ push_u32(&mut mission, u32::MAX);
+ mission.extend_from_slice(&(index as f32).to_le_bytes());
+ mission.extend_from_slice(&0.0_f32.to_le_bytes());
+ push_u32(&mut mission, 1);
+ push_lp(&mut mission, script_base.unwrap_or_default());
+ push_u32(&mut mission, 0);
+ push_lp(&mut mission, research_path);
+ push_u32(&mut mission, raw_tag as u32);
+ push_u32(&mut mission, 0);
+ }
+ push_u32(&mut mission, 10);
+ push_u32(&mut mission, 0);
+ push_lp(&mut mission, b"DATA\\MAPS\\SYNTHETIC\\land");
+ push_u32(&mut mission, 0);
+ push_lp(&mut mission, b"");
+ push_u32(&mut mission, 1);
+ push_u32(&mut mission, 0);
+
+ let land_mesh = empty_land_mesh();
+ let land_map = minimal_land_map();
+
+ let script_package = encode_script_package(&ScriptPackage {
+ opcode_handler_count: 73,
+ events: Vec::new(),
+ trailing_bytes: Vec::new(),
+ raw: Arc::from(&b""[..]),
+ });
+ let mut files = MemoryVfs::default();
+ for (path, bytes) in [
+ ("MISSIONS/TEST/data.tma", mission),
+ ("DATA/MAPS/SYNTHETIC/Land.msh", land_mesh),
+ ("DATA/MAPS/SYNTHETIC/Land.map", land_map),
+ ] {
+ files.insert(
+ normalize_engine_path("research startup test", path).expect("test path"),
+ Arc::from(bytes.into_boxed_slice()),
+ );
+ }
+ files.insert(
+ normalize_engine_path("test BuildDat", "BuildDat.lst").expect("BuildDat path"),
+ Arc::from(&b""[..]),
+ );
+ if script_bases.iter().any(Option::is_some) {
+ files.insert(
+ normalize_engine_path("test script", "MISSIONS/SCRIPTS/shared.scr")
+ .expect("script path"),
+ Arc::from(script_package.into_boxed_slice()),
+ );
+ files.insert(
+ normalize_engine_path("test varset", "MISSIONS/SCRIPTS/shared.var")
+ .expect("varset path"),
+ Arc::from(&b"VAR(DWORD, value, 0)\n"[..]),
+ );
+ }
+ if let Some(trf) = trf {
+ files.insert(
+ normalize_engine_path("test TRF", "DATA/Research/shared.trf").expect("TRF path"),
+ Arc::from(trf),
+ );
+ }
+ Arc::new(files)
+ }
+
+ #[test]
+ fn ai_record_slot_budget_error_keeps_loaded_mission_clock_and_rng() {
+ let valid_vfs = research_startup_test_vfs_with_raw_tag(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b""],
+ 9,
+ None,
+ );
+ let mut engine = create(EngineServices::new(valid_vfs)).expect("startup test engine");
+ let request = || MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ };
+ load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("initial valid full load");
+ let previous_mission = loaded_mission(&engine).expect("loaded mission").clone();
+ let previous_bundles = loaded_mission_script_bundles(&engine)
+ .expect("loaded script bundles")
+ .to_vec();
+ let previous_ai_startup_states = loaded_mission_ai_startup_states(&engine)
+ .expect("loaded AI startup states")
+ .to_vec();
+ assert_eq!(previous_ai_startup_states.len(), 2);
+ assert_eq!(
+ previous_ai_startup_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1]
+ );
+ assert!(previous_ai_startup_states
+ .iter()
+ .all(|state| state.record_slots == [u32::MAX; 9]));
+ assert_eq!(previous_bundles.len(), 1);
+ assert_eq!(previous_bundles[0].clan_index, 0);
+ assert_eq!(
+ previous_bundles[0].behavior_warm_start,
+ Some(BehaviorWarmStartState {
+ first_time_ms: 100_000,
+ next_deadline_ms: 100_202,
+ cleared_words: [0; 5],
+ })
+ );
+ let previous_rng = engine.behavior_rng_state;
+ assert_ne!(previous_rng, INITIAL_BEHAVIOR_RNG_STATE);
+ let previous_world = fparkan_world::canonical_state_hash(&engine.world);
+
+ engine.services.vfs = Some(research_startup_test_vfs_with_raw_tag(
+ &[None, None],
+ &[b"", b""],
+ -1,
+ None,
+ ));
+ let error = load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_500,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("negative raw tag is unsigned u32::MAX and exceeds the budget");
+
+ assert!(matches!(
+ error,
+ EngineError::MissionAiRecordSlots {
+ clan_index: 0,
+ raw_count: u32::MAX,
+ ..
+ }
+ ));
+ assert_eq!(loaded_mission(&engine), Some(&previous_mission));
+ assert_eq!(
+ loaded_mission_script_bundles(&engine),
+ Some(previous_bundles.as_slice())
+ );
+ assert_eq!(
+ loaded_mission_ai_startup_states(&engine),
+ Some(previous_ai_startup_states.as_slice())
+ );
+ assert_eq!(engine.behavior_rng_state, previous_rng);
+ assert_eq!(
+ fparkan_world::canonical_state_hash(&engine.world),
+ previous_world
+ );
+ }
+
+ #[test]
+ fn full_load_keeps_independent_trf_tables_for_each_ai_state_clan() {
+ let vfs = research_startup_test_vfs(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[
+ b"DATA/Research/shared.trf\0ignored.trf",
+ b"DATA/Research/shared.trf",
+ ],
+ Some(&research_test_trf()),
+ );
+ let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine");
+
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::AutoDemo,
+ },
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: true,
+ },
+ )
+ .expect("full load with explicit research gate");
+
+ let tables = loaded_mission_research_trf_tables(&engine).expect("TRF tables");
+ assert_eq!(tables.len(), 2, "each AI state owner gets a table");
+ let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
+ assert_eq!(bundles.len(), 1, "only the scripted clan has a bundle");
+ assert_eq!(bundles[0].clan_index, 0);
+ let ai_states = loaded_mission_ai_startup_states(&engine).expect("AI states");
+ assert_eq!(
+ ai_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1]
+ );
+ assert!(ai_states
+ .iter()
+ .all(|state| state.record_slots == [u32::MAX; 9]));
+ assert_eq!(tables[0].clan_index, 0);
+ assert_eq!(tables[0].path, "DATA/Research/shared.trf");
+ assert_eq!(tables[1].clan_index, 1);
+ assert_eq!(tables[1].path, "DATA/Research/shared.trf");
+ assert_eq!(
+ tables[0].document.row_words(0),
+ tables[1].document.row_words(0)
+ );
+ assert_eq!(tables[0].document.flag_byte(0), Some(0x5a));
+ assert_ne!(
+ tables[0].document.row_words(0).unwrap().as_ptr(),
+ tables[1].document.row_words(0).unwrap().as_ptr(),
+ "each clan owns an independently decoded row buffer"
+ );
+ }
+
+ #[test]
+ fn full_load_skips_research_trf_when_gate_or_c_string_is_missing() {
+ for (script_bases, research_paths, gate, mode) in [
+ (
+ vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])],
+ vec![&b"DATA/Research/missing.trf"[..]],
+ false,
+ MissionMode::AutoDemo,
+ ),
+ (
+ vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])],
+ vec![&b""[..]],
+ true,
+ MissionMode::SingleMission,
+ ),
+ (
+ vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])],
+ vec![&b"\0ignored.trf"[..]],
+ true,
+ MissionMode::SingleMission,
+ ),
+ ] {
+ let vfs = research_startup_test_vfs(&script_bases, &research_paths, None);
+ let mut engine =
+ create(EngineServices::new(vfs)).expect("research startup test engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode,
+ },
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: gate,
+ },
+ )
+ .expect("skipped TRF input must not be read");
+ assert!(loaded_mission_research_trf_tables(&engine)
+ .expect("TRF state")
+ .is_empty());
+ }
+ }
+
+ #[test]
+ fn static_preview_skips_research_trf_reads() {
+ let vfs = research_startup_test_vfs(
+ &[Some(b"MISSIONS/SCRIPTS/shared")],
+ &[b"DATA/Research/missing.trf"],
+ None,
+ );
+ let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine");
+
+ load_mission_static_preview(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ )
+ .expect("preview must not read research TRF paths");
+
+ assert!(loaded_mission_research_trf_tables(&engine)
+ .expect("TRF state")
+ .is_empty());
+ assert!(loaded_mission_ai_startup_states(&engine)
+ .expect("AI state")
+ .is_empty());
+ }
+
+ #[test]
+ fn no_script_ai_research_trf_read_and_decode_errors_keep_loaded_world_and_rng() {
+ let initial_vfs = research_startup_test_vfs(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b"DATA/Research/shared.trf"],
+ Some(&research_test_trf()),
+ );
+ let mut engine = create(EngineServices::new(initial_vfs)).expect("research startup engine");
+ let request = || MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ };
+ load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: true,
+ },
+ )
+ .expect("initial valid TRF table load");
+ let previous_summary = loaded_mission(&engine).expect("loaded mission").clone();
+ let previous_table =
+ &loaded_mission_research_trf_tables(&engine).expect("initial TRF table")[0];
+ let previous_owner = previous_table.clan_index;
+ let previous_path = previous_table.path.clone();
+ let previous_row = *previous_table.document.row_words(0).expect("initial row");
+ let previous_row_ptr = previous_table.document.row_words(0).unwrap().as_ptr();
+ let before = step_headless(&mut engine, InputSnapshot, 100_000)
+ .expect("step initial mission")
+ .snapshot
+ .objects;
+ let previous_rng = engine.behavior_rng_state;
+
+ for trf in [None, Some(&b"malformed"[..])] {
+ let vfs = research_startup_test_vfs(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b"DATA/Research/shared.trf"],
+ trf,
+ );
+ engine.services.vfs = Some(vfs);
+
+ let error = load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_100,
+ research_creation_gate: true,
+ },
+ )
+ .expect_err("gate true must report a missing or malformed TRF");
+ assert_eq!(loaded_mission(&engine), Some(&previous_summary));
+ assert_eq!(engine.behavior_rng_state, previous_rng);
+ let retained_tables =
+ loaded_mission_research_trf_tables(&engine).expect("retained TRF tables");
+ assert_eq!(retained_tables.len(), 1);
+ assert_eq!(retained_tables[0].clan_index, previous_owner);
+ assert_eq!(retained_tables[0].path, previous_path);
+ assert_eq!(
+ retained_tables[0].document.row_words(0),
+ Some(&previous_row)
+ );
+ assert_eq!(
+ retained_tables[0].document.row_words(0).unwrap().as_ptr(),
+ previous_row_ptr
+ );
+ let after = step_headless(&mut engine, InputSnapshot, 100_000)
+ .expect("step retained mission")
+ .snapshot
+ .objects;
+ assert_eq!(before, after);
+ match (trf, error) {
+ (None, EngineError::Vfs { path, .. }) => {
+ assert_eq!(path, "DATA/Research/shared.trf");
+ }
+ (
+ Some(_),
+ EngineError::ResearchTrf {
+ clan_index, path, ..
+ },
+ ) => {
+ assert_eq!(clan_index, 1);
+ assert_eq!(path, "DATA/Research/shared.trf");
+ }
+ (_, error) => panic!("unexpected TRF failure: {error:?}"),
+ }
+ }
+ }
+
+ #[test]
+ fn research_trf_c_string_paths_reject_non_ascii_bytes() {
+ let vfs = research_startup_test_vfs(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b"DATA/Research/\xe9.trf"],
+ None,
+ );
+ let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine");
+ let error = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: true,
+ },
+ )
+ .expect_err("non-ASCII TRF path must fail explicitly");
+ assert!(matches!(
+ error,
+ EngineError::ResearchTrfPath { clan_index: 1, path }
+ if path == b"DATA/Research/\xe9.trf"
+ ));
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_campaign_full_load_creates_trf_tables_for_ai_clan_owners() {
+ let root = licensed_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: true,
+ },
+ )
+ .expect("full campaign load with explicit research gate");
+
+ let tables = loaded_mission_research_trf_tables(&engine).expect("TRF tables");
+ assert_eq!(
+ tables
+ .iter()
+ .map(|table| (table.clan_index, table.path.as_str()))
+ .collect::<Vec<_>>(),
+ [
+ (0, "MISSIONS/SCRIPTS/data.trf"),
+ (1, "MISSIONS/SCRIPTS/data.trf"),
+ (2, "MISSIONS/SCRIPTS/data.trf"),
+ ]
+ );
+ assert!(tables.iter().all(|table| table.document.row_count() > 0));
+ assert_eq!(
+ tables[0].document.row_words(0),
+ tables[1].document.row_words(0)
+ );
+ assert_eq!(
+ tables[0].document.row_words(0),
+ tables[2].document.row_words(0)
+ );
+ assert_ne!(
+ tables[0].document.row_words(0).unwrap().as_ptr(),
+ tables[1].document.row_words(0).unwrap().as_ptr()
+ );
+ assert_ne!(
+ tables[1].document.row_words(0).unwrap().as_ptr(),
+ tables[2].document.row_words(0).unwrap().as_ptr()
+ );
+ }
+
#[derive(Clone, Copy)]
enum DenyRule {
Suffix(&'static str),
@@ -14472,24 +18447,37 @@ mod tests {
struct SwappingScriptVfs {
inner: DirectoryVfs,
- replacement: Arc<[u8]>,
+ replacement_script: Arc<[u8]>,
+ replacement_formula: Arc<[u8]>,
enabled: std::sync::atomic::AtomicBool,
}
impl SwappingScriptVfs {
- fn use_replacement(&self, path: &NormalizedPath) -> bool {
- self.enabled.load(std::sync::atomic::Ordering::SeqCst)
- && path
- .as_str()
- .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr")
+ fn replacement(&self, path: &NormalizedPath) -> Option<&Arc<[u8]>> {
+ if !self.enabled.load(std::sync::atomic::Ordering::SeqCst) {
+ return None;
+ }
+ if path
+ .as_str()
+ .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr")
+ {
+ Some(&self.replacement_script)
+ } else if path
+ .as_str()
+ .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.fml")
+ {
+ Some(&self.replacement_formula)
+ } else {
+ None
+ }
}
}
impl Vfs for SwappingScriptVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
- if self.use_replacement(path) {
+ if let Some(replacement) = self.replacement(path) {
return Ok(VfsMetadata {
- len: self.replacement.len() as u64,
+ len: replacement.len() as u64,
fingerprint: [0x18; 32],
});
}
@@ -14497,8 +18485,8 @@ mod tests {
}
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
- if self.use_replacement(path) {
- return Ok(self.replacement.clone());
+ if let Some(replacement) = self.replacement(path) {
+ return Ok(replacement.clone());
}
self.inner.read(path)
}
diff --git a/crates/fparkan-runtime/src/mesh_collision.rs b/crates/fparkan-runtime/src/mesh_collision.rs
new file mode 100644
index 0000000..6e9ae2d
--- /dev/null
+++ b/crates/fparkan-runtime/src/mesh_collision.rs
@@ -0,0 +1,1255 @@
+//! World-space segment queries over selected building MSH nodes and slots.
+
+use fparkan_msh::{
+ node38_metadata, query_slot_segment, ModelAsset, MshError, NodeId, SegmentQueryMasks, SlotId,
+};
+use fparkan_render::RawCameraTransform;
+
+/// One selected MSH node/slot and its affine transform into world space.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct BuildingMeshNode {
+ /// Model-local node id.
+ pub node: NodeId,
+ /// Currently selected slot for this node.
+ pub slot: SlotId,
+ /// Row-major affine transform from node-local space to world space.
+ pub world_from_node: [f32; 16],
+}
+
+/// One accepted building mesh hit in world space.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct BuildingMeshHit {
+ /// Node that supplied this hit.
+ pub node: NodeId,
+ /// Selected slot that supplied this hit.
+ pub slot: SlotId,
+ /// Hit position in world space.
+ pub position: [f32; 3],
+ /// Squared world distance from the original segment start.
+ pub distance_squared: f32,
+ /// Global Batch20 index.
+ pub batch_index: usize,
+ /// Global type-7 triangle descriptor index.
+ pub triangle_descriptor_index: usize,
+ /// Global Res3 position indices of the hit triangle.
+ pub vertex_indices: [usize; 3],
+}
+
+const MAX_INTERSECTION_VISITS: usize = 200;
+
+/// Queries a world-space segment against all supplied building nodes and slots.
+///
+/// Node flags use the native camera query's `0/0` masks. The initial batch
+/// query excludes `0x0200` while keeping bit `0x0008` visible to the building
+/// retry rule; triangle flags use `0/0x0024`. A nearest bit-8 batch hit restarts
+/// the query from that world-space point toward the original endpoint, just as
+/// the native building camera does. Each helper's Batch20 `local_batch_index`
+/// selects the next Node38 root; that retry visits its eligible descendants
+/// child-before-self and queries only nodes with supplied geometry bindings.
+pub fn query_building_segment_world(
+ model: &ModelAsset,
+ nodes: &[BuildingMeshNode],
+ world_segment: [[f32; 3]; 2],
+) -> Result<Option<BuildingMeshHit>, MshError> {
+ if !world_segment
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ {
+ return Err(MshError::InvalidGeometry(
+ "building query segment contains a non-finite endpoint".to_string(),
+ ));
+ }
+
+ let prepared_nodes = nodes
+ .iter()
+ .map(|node| {
+ prepare_node(*node).map_err(|reason| {
+ MshError::InvalidGeometry(format!(
+ "building node {} has an invalid world transform: {reason}",
+ node.node.0
+ ))
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+
+ let original_start = world_segment[0];
+ let original_end = world_segment[1];
+ let mut segment_start = original_start;
+ let mut linked_root = None;
+ let mut intersection_visits = 0;
+ loop {
+ let mut nearest = None;
+ let query_nodes = if let Some(root) = linked_root {
+ let order = linked_node_postorder(model, root)?;
+ let mut ordered = Vec::new();
+ for node_id in order {
+ ordered.extend(
+ prepared_nodes
+ .iter()
+ .filter(|node| node.source.node == node_id),
+ );
+ }
+ ordered
+ } else {
+ prepared_nodes.iter().collect()
+ };
+ for node in query_nodes {
+ let local_segment = [
+ transform_point(node.node_from_world, segment_start)
+ .ok_or_else(|| invalid_transformed_point(node.source.node, "world start"))?,
+ transform_point(node.node_from_world, original_end)
+ .ok_or_else(|| invalid_transformed_point(node.source.node, "world endpoint"))?,
+ ];
+ let hit = query_slot_segment(
+ model,
+ node.source.node,
+ node.source.slot,
+ local_segment,
+ SegmentQueryMasks {
+ node_include: 0,
+ node_exclude: 0,
+ batch_include: 0,
+ // CBuilding clears bit 8 from the camera's 0x208 exclude
+ // mask so it can restart after inspecting the raw batch.
+ batch_exclude: 0x0200,
+ triangle_include: 0,
+ triangle_exclude: 0x0024,
+ },
+ )
+ .map_err(|source| {
+ MshError::InvalidGeometry(format!(
+ "building node {} slot {} segment query failed: {source}",
+ node.source.node.0, node.source.slot.0
+ ))
+ })?;
+ let Some(hit) = hit else {
+ continue;
+ };
+ let position = transform_point(node.source.world_from_node, hit.position)
+ .ok_or_else(|| invalid_transformed_point(node.source.node, "local hit"))?;
+ let offset = subtract3(position, original_start);
+ let distance_squared = dot3(offset, offset);
+ if !distance_squared.is_finite() {
+ return Err(MshError::InvalidGeometry(format!(
+ "building node {} produced a non-finite world hit distance",
+ node.source.node.0
+ )));
+ }
+ let batch_flags = model
+ .batches
+ .get(hit.batch_index)
+ .ok_or_else(|| {
+ MshError::InvalidGeometry(format!(
+ "building node {} query returned missing batch {}",
+ node.source.node.0, hit.batch_index
+ ))
+ })?
+ .batch_flags;
+ let candidate = BuildingMeshHit {
+ node: node.source.node,
+ slot: node.source.slot,
+ position,
+ distance_squared,
+ batch_index: hit.batch_index,
+ triangle_descriptor_index: hit.triangle_descriptor_index,
+ vertex_indices: hit.vertex_indices,
+ };
+ if nearest.is_none_or(|(best, _): (BuildingMeshHit, u16)| {
+ candidate.distance_squared <= best.distance_squared
+ }) {
+ nearest = Some((candidate, batch_flags));
+ }
+ }
+
+ let Some((nearest, batch_flags)) = nearest else {
+ return Ok(None);
+ };
+ intersection_visits += 1;
+ if intersection_visits == MAX_INTERSECTION_VISITS {
+ return Err(MshError::InvalidGeometry(format!(
+ "building query exhausted the native {MAX_INTERSECTION_VISITS}-intersection visit limit"
+ )));
+ }
+ if batch_flags & 0x0008 == 0 {
+ return Ok(Some(nearest));
+ }
+ let batch = model.batches.get(nearest.batch_index).ok_or_else(|| {
+ MshError::InvalidGeometry(format!(
+ "building query returned missing batch {}",
+ nearest.batch_index
+ ))
+ })?;
+ if batch.local_batch_index == u16::MAX {
+ return Err(MshError::InvalidGeometry(format!(
+ "building helper batch {} has the sentinel linked-node key",
+ nearest.batch_index
+ )));
+ }
+ let linked_node = NodeId(u32::from(batch.local_batch_index));
+ if usize::try_from(linked_node.0).unwrap_or(usize::MAX) >= model.node_count {
+ return Err(MshError::InvalidGeometry(format!(
+ "building helper batch {} has linked node {} outside Node38 count {}",
+ nearest.batch_index, linked_node.0, model.node_count
+ )));
+ }
+ segment_start = nearest.position;
+ linked_root = Some(linked_node);
+ }
+}
+
+/// Builds the native child-before-self query order rooted at a portal link.
+///
+/// Node metadata is traversed even when the caller has no selected geometry
+/// binding for that node, because eligible descendants can still carry hits.
+fn linked_node_postorder(model: &ModelAsset, root: NodeId) -> Result<Vec<NodeId>, MshError> {
+ let root_index = usize::try_from(root.0).map_err(|_| {
+ MshError::InvalidGeometry(format!("linked query node {} is not addressable", root.0))
+ })?;
+ if root_index >= model.node_count {
+ return Err(MshError::InvalidGeometry(format!(
+ "linked query node {} is outside Node38 count {}",
+ root.0, model.node_count
+ )));
+ }
+
+ let metadata = (0..model.node_count)
+ .map(|index| {
+ let node = NodeId(u32::try_from(index).map_err(|_| {
+ MshError::InvalidGeometry(format!("Node38 index {index} is not addressable"))
+ })?);
+ node38_metadata(model, node).ok_or_else(|| {
+ MshError::InvalidGeometry(format!(
+ "linked query requires Node38 metadata for node {index}"
+ ))
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ for (index, entry) in metadata.iter().enumerate() {
+ if entry.parent_or_link_raw != u16::MAX
+ && usize::from(entry.parent_or_link_raw) >= model.node_count
+ {
+ return Err(MshError::InvalidGeometry(format!(
+ "Node38 parent {} for node {index} is outside node count {}",
+ entry.parent_or_link_raw, model.node_count
+ )));
+ }
+ }
+
+ // Each stack frame marks entry or postorder exit. This keeps traversal
+ // bounded by the model table rather than by the native hierarchy depth.
+ let mut stack = vec![(root_index, false)];
+ let mut active = vec![false; model.node_count];
+ let mut visited = vec![false; model.node_count];
+ let mut ordered = Vec::new();
+ while let Some((index, exiting)) = stack.pop() {
+ if exiting {
+ active[index] = false;
+ visited[index] = true;
+ ordered.push(NodeId(u32::try_from(index).map_err(|_| {
+ MshError::InvalidGeometry(format!("Node38 index {index} is not addressable"))
+ })?));
+ continue;
+ }
+ if active[index] {
+ return Err(MshError::InvalidGeometry(format!(
+ "linked Node38 query contains a cycle at node {index}"
+ )));
+ }
+ if visited[index] {
+ continue;
+ }
+
+ active[index] = true;
+ stack.push((index, true));
+ if metadata[index].header_flags & 0x0010 == 0 {
+ let parent_word = u16::try_from(index).map_err(|_| {
+ MshError::InvalidGeometry(format!(
+ "Node38 index {index} cannot be represented by its parent field"
+ ))
+ })?;
+ for child in (0..model.node_count).rev() {
+ if metadata[child].parent_or_link_raw != u16::MAX
+ && metadata[child].parent_or_link_raw == parent_word
+ && metadata[child].header_flags & 0x0004 == 0
+ {
+ stack.push((child, false));
+ }
+ }
+ }
+ }
+ Ok(ordered)
+}
+
+#[derive(Clone, Copy)]
+struct PreparedNode {
+ source: BuildingMeshNode,
+ node_from_world: [f32; 16],
+}
+
+fn prepare_node(node: BuildingMeshNode) -> Result<PreparedNode, &'static str> {
+ if !node.world_from_node.iter().all(|value| value.is_finite()) {
+ return Err("matrix contains a non-finite value");
+ }
+ if node.world_from_node[12].abs() > f32::EPSILON
+ || node.world_from_node[13].abs() > f32::EPSILON
+ || node.world_from_node[14].abs() > f32::EPSILON
+ || (node.world_from_node[15] - 1.0).abs() > f32::EPSILON
+ {
+ return Err("matrix is not affine");
+ }
+ let node_from_world = RawCameraTransform {
+ words: node.world_from_node.map(f32::to_bits),
+ }
+ .try_inverse_affine_row_major()
+ .ok_or("matrix is singular or its inverse is not finite")?;
+ if !node_from_world.iter().all(|value| value.is_finite()) {
+ return Err("matrix inverse contains a non-finite value");
+ }
+ Ok(PreparedNode {
+ source: node,
+ node_from_world,
+ })
+}
+
+fn invalid_transformed_point(node: NodeId, label: &str) -> MshError {
+ MshError::InvalidGeometry(format!(
+ "building node {} transformed {label} to a non-finite point",
+ node.0
+ ))
+}
+
+fn transform_point(matrix: [f32; 16], point: [f32; 3]) -> Option<[f32; 3]> {
+ let transformed = [
+ matrix[0] * point[0] + matrix[1] * point[1] + matrix[2] * point[2] + matrix[3],
+ matrix[4] * point[0] + matrix[5] * point[1] + matrix[6] * point[2] + matrix[7],
+ matrix[8] * point[0] + matrix[9] * point[1] + matrix[10] * point[2] + matrix[11],
+ ];
+ transformed
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(transformed)
+}
+
+fn subtract3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
+}
+
+fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 {
+ left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ query_building_segment_world, BuildingMeshHit, BuildingMeshNode, MAX_INTERSECTION_VISITS,
+ };
+ use fparkan_msh::{
+ query_slot_segment, Batch, ModelAsset, MshError, NodeId, SegmentQueryMasks, Slot, SlotId,
+ TriangleDescriptor,
+ };
+
+ const IDENTITY: [f32; 16] = [
+ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
+ ];
+ const CAMERA_MASKS: SegmentQueryMasks = SegmentQueryMasks {
+ node_include: 0,
+ node_exclude: 0,
+ batch_include: 0,
+ batch_exclude: 0x0200,
+ triangle_include: 0,
+ triangle_exclude: 0x0024,
+ };
+
+ fn node(node: u32, world_from_node: [f32; 16]) -> BuildingMeshNode {
+ node_with_slot(node, 0, world_from_node)
+ }
+
+ fn node_with_slot(node: u32, slot: u32, world_from_node: [f32; 16]) -> BuildingMeshNode {
+ BuildingMeshNode {
+ node: NodeId(node),
+ slot: SlotId(slot),
+ world_from_node,
+ }
+ }
+
+ fn model_with_planes(planes: &[(f32, u16)]) -> ModelAsset {
+ model_with_slots(&[planes])
+ }
+
+ fn model_with_slots(planes_per_slot: &[&[(f32, u16)]]) -> ModelAsset {
+ model_with_slots_and_nodes(planes_per_slot, 2)
+ }
+
+ fn model_with_slots_and_nodes(
+ planes_per_slot: &[&[(f32, u16)]],
+ node_count: usize,
+ ) -> ModelAsset {
+ let mut positions = Vec::new();
+ let mut indices = Vec::new();
+ let mut batches = Vec::new();
+ let mut triangle_descriptors = Vec::new();
+ let mut slots = Vec::new();
+ for planes in planes_per_slot {
+ let descriptor_start = triangle_descriptors.len();
+ let batch_start = batches.len();
+ let min_y = planes.iter().map(|(y, _)| *y).fold(0.0, f32::min);
+ let max_y = planes.iter().map(|(y, _)| *y).fold(0.0, f32::max);
+ let center_y = (min_y + max_y) * 0.5;
+ let half_height = (max_y - min_y) * 0.5;
+ for (y, batch_flags) in *planes {
+ let base_vertex = positions.len();
+ let index_start = indices.len();
+ positions.extend([[-20.0, *y, -20.0], [0.0, *y, 20.0], [20.0, *y, -20.0]]);
+ indices.extend([0, 1, 2]);
+ batches.push(Batch {
+ batch_flags: *batch_flags,
+ material_index_hi: 0,
+ material_index: 0,
+ lightmap_index: 0,
+ local_batch_index: u16::MAX,
+ index_count: 3,
+ index_start: u32::try_from(index_start).expect("small synthetic index offset"),
+ vertex_count: 3,
+ base_vertex: u32::try_from(base_vertex).expect("small synthetic vertex offset"),
+ });
+ triangle_descriptors.push(TriangleDescriptor {
+ tri_flags: 0,
+ opaque: [0; 4],
+ normal: [0, 32767, 0],
+ });
+ }
+ slots.push(Slot {
+ tri_start: u16::try_from(descriptor_start)
+ .expect("small synthetic descriptor start"),
+ tri_count: u16::try_from(triangle_descriptors.len() - descriptor_start)
+ .expect("small synthetic descriptor count"),
+ batch_start: u16::try_from(batch_start).expect("small synthetic batch start"),
+ batch_count: u16::try_from(batches.len() - batch_start)
+ .expect("small synthetic batch count"),
+ aabb_min: [-20.0, min_y, -20.0],
+ aabb_max: [20.0, max_y, 20.0],
+ sphere_center: [0.0, center_y, 0.0],
+ sphere_radius: (20.0_f32.powi(2) * 2.0 + half_height.powi(2)).sqrt(),
+ opaque: [0; 5],
+ });
+ }
+
+ ModelAsset {
+ node_stride: 38,
+ node_count,
+ nodes_raw: vec![0; node_count * 38],
+ type16_present: false,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 0.0,
+ slots,
+ positions,
+ normals: None,
+ uv0: None,
+ uv1: None,
+ indices,
+ batches,
+ triangle_descriptors: Some(triangle_descriptors),
+ node_names: None,
+ animation: None,
+ }
+ }
+
+ fn set_node_metadata(model: &mut ModelAsset, node: usize, flags: u16, parent: u16) {
+ let offset = node * model.node_stride;
+ model.nodes_raw[offset..offset + 2].copy_from_slice(&flags.to_le_bytes());
+ model.nodes_raw[offset + 2..offset + 4].copy_from_slice(&parent.to_le_bytes());
+ }
+
+ fn set_batch_link(model: &mut ModelAsset, batch: usize, node: u16) {
+ model.batches[batch].local_batch_index = node;
+ }
+
+ fn set_root_node_metadata(model: &mut ModelAsset) {
+ for node in 0..model.node_count {
+ set_node_metadata(model, node, 0, u16::MAX);
+ }
+ }
+
+ fn translated(x: f32, y: f32, z: f32) -> [f32; 16] {
+ let mut matrix = IDENTITY;
+ matrix[3] = x;
+ matrix[7] = y;
+ matrix[11] = z;
+ matrix
+ }
+
+ fn hit(result: Option<BuildingMeshHit>) -> BuildingMeshHit {
+ result.expect("segment should hit the synthetic plane")
+ }
+
+ fn assert_point(actual: [f32; 3], expected: [f32; 3]) {
+ for axis in 0..3 {
+ assert!(
+ (actual[axis] - expected[axis]).abs() < 1.0e-4,
+ "{actual:?} != {expected:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn translation_returns_a_world_space_hit() {
+ let result = query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, translated(4.0, 5.0, -3.0))],
+ [[4.0, 8.0, -3.0], [4.0, 2.0, -3.0]],
+ )
+ .expect("valid world query");
+
+ let hit = hit(result);
+ assert_point(hit.position, [4.0, 5.0, -3.0]);
+ assert_eq!(hit.distance_squared, 9.0);
+ }
+
+ #[test]
+ fn rotation_transforms_the_world_segment_into_node_space() {
+ let rotated = [
+ 0.0, -1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
+ ];
+ let result = query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, rotated)],
+ [[-3.0, 0.0, 0.0], [3.0, 0.0, 0.0]],
+ )
+ .expect("valid world query");
+
+ assert_point(hit(result).position, [0.0, 0.0, 0.0]);
+ }
+
+ #[test]
+ fn nonuniform_scale_preserves_world_hit_coordinates() {
+ let scaled = [
+ 2.0, 0.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 1.0,
+ ];
+ let result = query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, scaled)],
+ [[0.0, 2.0, 0.25], [0.0, -2.0, 0.25]],
+ )
+ .expect("valid world query");
+
+ let hit = hit(result);
+ assert_point(hit.position, [0.0, 0.0, 0.25]);
+ assert_eq!(hit.distance_squared, 4.0);
+ }
+
+ #[test]
+ fn mirrored_transform_uses_the_mirrored_world_side() {
+ let mirrored = [
+ 1.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
+ ];
+ let result = query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, mirrored)],
+ [[0.0, -3.0, 0.0], [0.0, 3.0, 0.0]],
+ )
+ .expect("valid mirrored world query");
+
+ assert_point(hit(result).position, [0.0, 0.0, 0.0]);
+ }
+
+ #[test]
+ fn nearest_hit_is_ranked_in_world_space_from_original_start() {
+ let mut farther = translated(0.0, 0.0, 0.0);
+ let mut nearer = translated(0.0, 2.0, 0.0);
+ // Make local distances deliberately incomparable across nodes.
+ farther[5] = 2.0;
+ nearer[5] = 0.5;
+ let result = query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, farther), node(1, nearer)],
+ [[0.0, 5.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid world query");
+
+ let hit = hit(result);
+ assert_eq!(hit.node, NodeId(1));
+ assert_point(hit.position, [0.0, 2.0, 0.0]);
+ assert_eq!(hit.distance_squared, 9.0);
+ }
+
+ #[test]
+ fn retries_from_bit8_helper_to_the_farther_wall() {
+ let helper_plane = [(2.0, 0x0008)];
+ let wall_plane = [(0.0, 0)];
+ let mut model = model_with_slots(&[&helper_plane, &wall_plane]);
+ set_root_node_metadata(&mut model);
+ set_batch_link(&mut model, 0, 1);
+ let result = query_building_segment_world(
+ &model,
+ &[
+ node_with_slot(0, 0, IDENTITY),
+ node_with_slot(1, 1, IDENTITY),
+ ],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid world query");
+
+ let hit = hit(result);
+ assert_eq!(hit.node, NodeId(1));
+ assert_eq!(hit.slot, SlotId(1));
+ assert_eq!(hit.batch_index, 1);
+ assert_point(hit.position, [0.0, 0.0, 0.0]);
+ assert_eq!(hit.distance_squared, 9.0);
+ }
+
+ #[test]
+ fn coplanar_helper_hit_is_requeried_instead_of_filtered_out() {
+ let mut model = model_with_slots(&[&[(0.0, 0)], &[(0.0, 0x0008)]]);
+ set_root_node_metadata(&mut model);
+ set_batch_link(&mut model, 1, 1);
+ let result = query_building_segment_world(
+ &model,
+ &[
+ node_with_slot(1, 0, IDENTITY),
+ node_with_slot(0, 1, IDENTITY),
+ ],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid world query");
+
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn world_roundtrip_helper_cannot_be_reselected_after_portal_link() {
+ const START: [f32; 3] = [301.39682, 806.24396, 2.868942];
+ const END: [f32; 3] = [304.3922, 806.4102, 2.868942];
+ let world_from_node = [
+ f32::from_bits(0xbd62_f745),
+ f32::from_bits(0xbf7f_9b4f),
+ 0.0,
+ f32::from_bits(0x4379_43ca),
+ f32::from_bits(0x3f7f_9b4f),
+ f32::from_bits(0xbd62_f745),
+ 0.0,
+ f32::from_bits(0x4448_b667),
+ 0.0,
+ 0.0,
+ 1.0,
+ f32::from_bits(0x4110_2494),
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ];
+ let mut model = model_with_slots_and_nodes(&[&[(-54.239994, 0x0008)]], 4);
+ set_batch_link(&mut model, 0, 2);
+ set_node_metadata(&mut model, 0, 0, u16::MAX);
+ set_node_metadata(&mut model, 1, 0x0104, 0);
+ set_node_metadata(&mut model, 2, 0x0004, 1);
+ set_node_metadata(&mut model, 3, 0x00d1, 2);
+ let source = node(1, world_from_node);
+
+ // Reproduce the old all-node retry: the world-space first hit maps
+ // back to a local start just above this same helper plane.
+ let prepared = super::prepare_node(source).expect("invert actual AutoDemo matrix");
+ let local_start =
+ super::transform_point(prepared.node_from_world, START).expect("finite local start");
+ let local_end =
+ super::transform_point(prepared.node_from_world, END).expect("finite local endpoint");
+ let first_hit = query_slot_segment(
+ &model,
+ NodeId(1),
+ SlotId(0),
+ [local_start, local_end],
+ CAMERA_MASKS,
+ )
+ .expect("valid first helper query")
+ .expect("first helper crossing");
+ let world_hit =
+ super::transform_point(world_from_node, first_hit.position).expect("finite world hit");
+ let repeated_local_start = super::transform_point(prepared.node_from_world, world_hit)
+ .expect("finite round-tripped start");
+ assert!(query_slot_segment(
+ &model,
+ NodeId(1),
+ SlotId(0),
+ [repeated_local_start, local_end],
+ CAMERA_MASKS,
+ )
+ .expect("valid repeated helper query")
+ .is_some());
+
+ let result = query_building_segment_world(&model, &[source], [START, END])
+ .expect("valid portal-linked world query");
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn linked_subtree_finds_drawable_child_through_nondrawable_parent() {
+ let mut model = model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)]], 4);
+ set_root_node_metadata(&mut model);
+ set_batch_link(&mut model, 0, 2);
+ set_node_metadata(&mut model, 1, 0x0104, 0);
+ set_node_metadata(&mut model, 2, 0x0004, 1);
+ set_node_metadata(&mut model, 3, 0x00d1, 2);
+
+ let result = query_building_segment_world(
+ &model,
+ &[
+ node_with_slot(1, 0, IDENTITY),
+ node_with_slot(3, 1, IDENTITY),
+ ],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid linked-child query");
+
+ let hit = hit(result);
+ assert_eq!(hit.node, NodeId(3));
+ assert_eq!(hit.slot, SlotId(1));
+ assert_eq!(hit.batch_index, 1);
+ assert_point(hit.position, [0.0, 0.0, 0.0]);
+ }
+
+ #[test]
+ fn linked_query_is_child_before_self_and_self_bit4_does_not_skip_geometry() {
+ let mut model =
+ model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0)]], 4);
+ set_root_node_metadata(&mut model);
+ set_batch_link(&mut model, 0, 2);
+ set_node_metadata(&mut model, 1, 0x0104, 0);
+ set_node_metadata(&mut model, 2, 0x0004, 1);
+ set_node_metadata(&mut model, 3, 0x00d1, 2);
+
+ let result = query_building_segment_world(
+ &model,
+ &[
+ node_with_slot(1, 0, IDENTITY),
+ node_with_slot(2, 1, IDENTITY),
+ node_with_slot(3, 2, IDENTITY),
+ ],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid child-first linked query");
+
+ let hit = hit(result);
+ // The equal-distance later candidate replaces the child result.
+ assert_eq!(hit.node, NodeId(2));
+ assert_eq!(hit.slot, SlotId(1));
+ }
+
+ #[test]
+ fn linked_query_traverses_nondrawable_nodes_and_skips_child_bit4_branches() {
+ let mut model = model_with_slots_and_nodes(
+ &[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0)], &[(0.0, 0)]],
+ 7,
+ );
+ set_root_node_metadata(&mut model);
+ set_batch_link(&mut model, 0, 2);
+ set_node_metadata(&mut model, 1, 0x0104, 0);
+ set_node_metadata(&mut model, 2, 0x0004, 1);
+ set_node_metadata(&mut model, 3, 0x0000, 2);
+ set_node_metadata(&mut model, 4, 0x0010, 3);
+ set_node_metadata(&mut model, 5, 0x0004, 3);
+ set_node_metadata(&mut model, 6, 0x0004, 2);
+
+ let result = query_building_segment_world(
+ &model,
+ &[
+ node_with_slot(1, 0, IDENTITY),
+ node_with_slot(4, 1, IDENTITY),
+ node_with_slot(5, 2, IDENTITY),
+ node_with_slot(6, 3, IDENTITY),
+ ],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid linked branch query");
+
+ let hit = hit(result);
+ assert_eq!(hit.node, NodeId(4));
+ assert_eq!(hit.slot, SlotId(1));
+ }
+
+ #[test]
+ fn linked_query_keeps_native_batch_and_triangle_exclude_masks() {
+ let mut model =
+ model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0x0200)]], 5);
+ set_root_node_metadata(&mut model);
+ set_batch_link(&mut model, 0, 2);
+ set_node_metadata(&mut model, 1, 0x0104, 0);
+ set_node_metadata(&mut model, 2, 0x0004, 1);
+ set_node_metadata(&mut model, 3, 0x00d1, 2);
+ set_node_metadata(&mut model, 4, 0x00d1, 2);
+ model
+ .triangle_descriptors
+ .as_mut()
+ .expect("synthetic type-7 table")[1]
+ .tri_flags = 0x0024;
+
+ let result = query_building_segment_world(
+ &model,
+ &[
+ node_with_slot(1, 0, IDENTITY),
+ node_with_slot(3, 1, IDENTITY),
+ node_with_slot(4, 2, IDENTITY),
+ ],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid query with native exclusion masks");
+
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn linked_order_stops_descent_at_header_bit10_but_keeps_self() {
+ let mut model = model_with_slots_and_nodes(&[&[]], 5);
+ set_root_node_metadata(&mut model);
+ set_node_metadata(&mut model, 1, 0, 0);
+ set_node_metadata(&mut model, 2, 0x0014, 1);
+ set_node_metadata(&mut model, 3, 0, 2);
+ set_node_metadata(&mut model, 4, 0, 3);
+
+ let order = super::linked_node_postorder(&model, NodeId(2)).expect("valid linked tree");
+
+ assert_eq!(order, [NodeId(2)]);
+ }
+
+ #[test]
+ fn invalid_portal_link_and_linked_cycle_are_explicit_errors() {
+ let mut invalid_link = model_with_planes(&[(2.0, 0x0008)]);
+ set_root_node_metadata(&mut invalid_link);
+ set_batch_link(&mut invalid_link, 0, 2);
+ assert!(matches!(
+ query_building_segment_world(
+ &invalid_link,
+ &[node(0, IDENTITY)],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ ),
+ Err(MshError::InvalidGeometry(message)) if message.contains("linked")
+ ));
+
+ let mut cycle = model_with_slots_and_nodes(&[&[(2.0, 0x0008)]], 3);
+ set_batch_link(&mut cycle, 0, 1);
+ set_node_metadata(&mut cycle, 0, 0, u16::MAX);
+ set_node_metadata(&mut cycle, 1, 0, 2);
+ set_node_metadata(&mut cycle, 2, 0, 1);
+ assert!(matches!(
+ query_building_segment_world(
+ &cycle,
+ &[node(0, IDENTITY)],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ ),
+ Err(MshError::InvalidGeometry(message)) if message.contains("cycle")
+ ));
+ }
+
+ #[test]
+ fn invalid_non_affine_singular_and_nonfinite_transforms_are_explicit_errors() {
+ let mut non_affine = IDENTITY;
+ non_affine[12] = 0.5;
+ assert!(matches!(
+ query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, non_affine)],
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ ),
+ Err(MshError::InvalidGeometry(message)) if message.contains("not affine")
+ ));
+
+ let mut singular = IDENTITY;
+ singular[5] = 0.0;
+ assert!(matches!(
+ query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, singular)],
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ ),
+ Err(MshError::InvalidGeometry(message)) if message.contains("singular")
+ ));
+
+ let mut nonfinite = IDENTITY;
+ nonfinite[0] = f32::NAN;
+ assert!(matches!(
+ query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, nonfinite)],
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ ),
+ Err(MshError::InvalidGeometry(message)) if message.contains("non-finite")
+ ));
+ }
+
+ #[test]
+ fn wall_after_198_helpers_is_accepted_as_the_199th_hit() {
+ let helper_count = MAX_INTERSECTION_VISITS - 2;
+ let mut planes = (0..helper_count)
+ .map(|index| ((helper_count - index) as f32, 0x0008))
+ .collect::<Vec<_>>();
+ planes.push((0.0, 0));
+ let mut model = model_with_planes(&planes);
+ set_root_node_metadata(&mut model);
+ for batch in 0..helper_count {
+ set_batch_link(&mut model, batch, 0);
+ }
+ let result = query_building_segment_world(
+ &model,
+ &[node(0, IDENTITY)],
+ [[0.0, (helper_count + 1) as f32, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("199th intersection is below the native limit");
+
+ let hit = hit(result);
+ assert_eq!(hit.batch_index, helper_count);
+ assert_point(hit.position, [0.0, 0.0, 0.0]);
+ }
+
+ #[test]
+ fn wall_after_199_helpers_errors_as_the_200th_hit() {
+ let helper_count = MAX_INTERSECTION_VISITS - 1;
+ let mut planes = (0..helper_count)
+ .map(|index| ((helper_count - index) as f32, 0x0008))
+ .collect::<Vec<_>>();
+ planes.push((0.0, 0));
+ let mut model = model_with_planes(&planes);
+ set_root_node_metadata(&mut model);
+ for batch in 0..helper_count {
+ set_batch_link(&mut model, batch, 0);
+ }
+ let result = query_building_segment_world(
+ &model,
+ &[node(0, IDENTITY)],
+ [[0.0, (helper_count + 1) as f32, 0.0], [0.0, -1.0, 0.0]],
+ );
+
+ assert!(matches!(
+ result,
+ Err(MshError::InvalidGeometry(message)) if message.contains("200")
+ ));
+ }
+
+ fn compose_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
+ let mut result = [0.0; 16];
+ for row in 0..4 {
+ for column in 0..4 {
+ for inner in 0..4 {
+ result[row * 4 + column] += left[row * 4 + inner] * right[inner * 4 + column];
+ }
+ }
+ }
+ result
+ }
+
+ fn pose_matrix(pose: fparkan_animation::Pose) -> [f32; 16] {
+ let x = rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation);
+ let y = rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation);
+ let z = rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation);
+ [
+ x[0],
+ y[0],
+ z[0],
+ pose.translation[0],
+ x[1],
+ y[1],
+ z[1],
+ pose.translation[1],
+ x[2],
+ y[2],
+ z[2],
+ pose.translation[2],
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ]
+ }
+
+ fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
+ let [x, y, z, w] = rotation;
+ let tx = 2.0 * (y * value[2] - z * value[1]);
+ let ty = 2.0 * (z * value[0] - x * value[2]);
+ let tz = 2.0 * (x * value[1] - y * value[0]);
+ [
+ value[0] + w * tx + (y * tz - z * ty),
+ value[1] + w * ty + (z * tx - x * tz),
+ value[2] + w * tz + (x * ty - y * tx),
+ ]
+ }
+
+ fn scale_matrix(scale: [f32; 3]) -> [f32; 16] {
+ [
+ scale[0], 0.0, 0.0, 0.0, 0.0, scale[1], 0.0, 0.0, 0.0, 0.0, scale[2], 0.0, 0.0, 0.0,
+ 0.0, 1.0,
+ ]
+ }
+
+ fn node_times_from_bindings(
+ node_count: usize,
+ bindings: &[fparkan_assets::PreparedControlNodeBinding],
+ ) -> Vec<Option<fparkan_animation::AnimationTime>> {
+ use fparkan_animation::AnimationTime;
+
+ let mut times = vec![Some(AnimationTime(0.0)); node_count];
+ for binding in bindings {
+ if binding.flags & 0x04 != 0 {
+ continue;
+ }
+ assert!(binding.node_index < node_count);
+ let mut blend = if binding.flags & 0x01 != 0 {
+ let mut value = binding.initial_blend;
+ if value > 1.0 {
+ value -= 1.0;
+ }
+ if value < 0.0 {
+ value += 1.0;
+ }
+ value
+ } else {
+ binding.initial_blend.clamp(0.0, 1.0)
+ };
+ if binding.flags & 0x02 != 0 {
+ blend = 1.0 - blend;
+ }
+ times[binding.node_index] = Some(AnimationTime(
+ (1.0 - blend) * binding.frame_a + blend * binding.frame_b,
+ ));
+ }
+ times
+ }
+
+ fn nodes_for_hierarchy(
+ model: &ModelAsset,
+ poses: &[fparkan_animation::Pose],
+ root_from_body: [f32; 16],
+ owner_scale: [f32; 3],
+ ) -> Vec<BuildingMeshNode> {
+ use fparkan_msh::{selected_slot, Group, Lod};
+
+ let scale = scale_matrix(owner_scale);
+ (0..model.node_count)
+ .filter_map(|index| {
+ let node = NodeId(u32::try_from(index).expect("model node id fits u32"));
+ let slot = selected_slot(model, node, Lod(0), Group(0))?;
+ let slot_data = &model.slots[usize::try_from(slot.0).expect("slot id fits usize")];
+ let batch_start = usize::from(slot_data.batch_start);
+ let batch_end = batch_start + usize::from(slot_data.batch_count);
+ if !model.batches[batch_start..batch_end]
+ .iter()
+ .any(|batch| batch.index_count != 0)
+ {
+ return None;
+ }
+ let node_pose = pose_matrix(*poses.get(index).expect("Node38 pose available"));
+ let body_from_node = compose_row_major(scale, node_pose);
+ let world_from_node = compose_row_major(root_from_body, body_from_node);
+ Some(BuildingMeshNode {
+ node,
+ slot,
+ world_from_node,
+ })
+ })
+ .collect()
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn factory_plant_world_query_preserves_native_fixture_hit_under_affine_transforms() {
+ use fparkan_assets::decode_nres_payload;
+ use fparkan_msh::{
+ decode_msh, node38_fallback_hierarchy, selected_slot, validate_msh, Group, Lod, NodeId,
+ SlotId,
+ };
+ use std::{path::PathBuf, sync::Arc};
+
+ const BODY_WALL: [[f32; 3]; 2] = [[-10.5, 22.16, 2.5], [-10.5, 19.16, 2.5]];
+ const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]];
+ const BODY_REVERSED_WALL: [[f32; 3]; 2] = [BODY_WALL[1], BODY_WALL[0]];
+ const BODY_HIT: [f32; 3] = [-10.5, 20.16, 2.5];
+ const ROTATED_SCALED_TRANSLATED: [f32; 16] = [
+ 0.0, -3.0, 0.0, 7.0, 2.0, 0.0, 0.0, -4.0, 0.0, 0.0, 0.5, 2.0, 0.0, 0.0, 0.0, 1.0,
+ ];
+
+ let root = PathBuf::from(
+ std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed corpus tests"),
+ );
+ let archive_bytes = std::fs::read(root.join("fortif.rlb")).expect("fortif.rlb bytes");
+ let archive = decode_nres_payload(Arc::from(archive_bytes.into_boxed_slice()))
+ .expect("fortif.rlb archive");
+ let entry = archive
+ .entries()
+ .iter()
+ .find(|entry| entry.name_bytes().eq_ignore_ascii_case(b"fr_l_plant.msh"))
+ .expect("plant factory MSH");
+ let nested = decode_nres_payload(Arc::from(
+ archive
+ .payload(entry.id())
+ .expect("MSH payload")
+ .to_vec()
+ .into_boxed_slice(),
+ ))
+ .expect("nested plant MSH");
+ let model = validate_msh(&decode_msh(&nested).expect("plant MSH document"))
+ .expect("plant MSH model");
+ let node_id = NodeId(1);
+ let slot = selected_slot(&model, node_id, Lod(0), Group(0)).expect("Node 1 slot");
+ assert_eq!(slot, SlotId(0));
+
+ let pose = node38_fallback_hierarchy(&model)
+ .expect("Node38 fallback hierarchy")
+ .poses[1];
+ assert_eq!(pose.rotation, [0.0, 0.0, 0.0, 1.0]);
+ let body_from_node = translated(
+ pose.translation[0],
+ pose.translation[1],
+ pose.translation[2],
+ );
+
+ for body_to_world in [IDENTITY, ROTATED_SCALED_TRANSLATED] {
+ let world_from_node = compose_row_major(body_to_world, body_from_node);
+ let node = BuildingMeshNode {
+ node: node_id,
+ slot,
+ world_from_node,
+ };
+ let to_world = |point| {
+ super::transform_point(body_to_world, point).expect("finite fixture transform")
+ };
+ let world_wall = [to_world(BODY_WALL[0]), to_world(BODY_WALL[1])];
+ let expected_hit = to_world(BODY_HIT);
+ let hit = query_building_segment_world(&model, &[node], world_wall)
+ .expect("valid factory wall query")
+ .expect("solid factory wall hit");
+
+ assert_eq!(hit.node, node_id);
+ assert_eq!(hit.slot, slot);
+ assert_eq!(hit.batch_index, 5);
+ assert_eq!(hit.triangle_descriptor_index, 62);
+ for axis in 0..3 {
+ assert!(
+ (hit.position[axis] - expected_hit[axis]).abs() <= 1.0e-3,
+ "world hit {:?} != {:?}",
+ hit.position,
+ expected_hit
+ );
+ }
+ let world_delta = [
+ expected_hit[0] - world_wall[0][0],
+ expected_hit[1] - world_wall[0][1],
+ expected_hit[2] - world_wall[0][2],
+ ];
+ let expected_distance_squared = super::dot3(world_delta, world_delta);
+ assert!(
+ (hit.distance_squared - expected_distance_squared).abs() <= 1.0e-2,
+ "world distance squared {} != {}",
+ hit.distance_squared,
+ expected_distance_squared
+ );
+
+ for body_segment in [BODY_DOOR, BODY_REVERSED_WALL] {
+ let world_segment = [to_world(body_segment[0]), to_world(body_segment[1])];
+ assert!(query_building_segment_world(&model, &[node], world_segment)
+ .expect("valid factory miss query")
+ .is_none());
+ }
+ }
+ }
+
+ #[test]
+ #[ignore = "requires licensed AutoDemo corpus"]
+ fn autodemo_portal_uses_node3_authored_closed_and_open_poses() {
+ use crate::{
+ create, load_mission_static_preview_roots, loaded_mission_assets,
+ loaded_mission_object_drafts, EngineServices, MissionMode, MissionRequest,
+ };
+ use fparkan_animation::AnimationTime;
+ use fparkan_msh::{node38_fallback_hierarchy, node38_sampled_hierarchy_at_times};
+ use fparkan_render::LegacyIron3dEulerTransform;
+ use fparkan_vfs::DirectoryVfs;
+ use std::{num::NonZeroUsize, path::PathBuf, sync::Arc};
+
+ const OBJECT_INDEX: usize = 4;
+ const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]];
+
+ let root = PathBuf::from(
+ std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed corpus tests"),
+ );
+ let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(&root))))
+ .expect("create SingleMission fixture engine");
+ load_mission_static_preview_roots(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ NonZeroUsize::MAX,
+ )
+ .expect("load SingleMission preview roots");
+ let assets = loaded_mission_assets(&engine).expect("loaded mission assets");
+ let drafts = loaded_mission_object_drafts(&engine).expect("loaded object drafts");
+ let draft = &drafts[OBJECT_INDEX];
+ assert!(draft
+ .resource_name_raw
+ .eq_ignore_ascii_case(b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat"));
+ let visual_id = *assets
+ .visuals_for_object(OBJECT_INDEX)
+ .first()
+ .expect("PLANT root visual");
+ let visual = assets.visual_by_id(visual_id).expect("PLANT visual");
+ let model_id = visual.model_id.expect("PLANT MSH model");
+ let model = &assets
+ .model_by_id(model_id)
+ .expect("prepared PLANT model")
+ .validated;
+ assert_eq!(model.node_count, 29);
+
+ let controls = assets
+ .object_component_controls
+ .get(OBJECT_INDEX)
+ .and_then(|components| components.first())
+ .and_then(Option::as_ref)
+ .expect("PLANT component CTLD bindings");
+ let node3_binding = controls
+ .bindings
+ .iter()
+ .find(|binding| binding.node_index == 3)
+ .expect("Node3 CTLD binding");
+ assert_eq!(node3_binding.frame_a, 0.0);
+ assert_eq!(node3_binding.frame_b, 1.0);
+ assert_eq!(node3_binding.initial_blend, 0.0);
+
+ let mut node_times = node_times_from_bindings(model.node_count, &controls.bindings);
+ let closed_hierarchy = node38_sampled_hierarchy_at_times(model, &node_times)
+ .or_else(|| node38_fallback_hierarchy(model))
+ .expect("initial Node38 hierarchy");
+ assert_eq!(node_times[3], Some(AnimationTime(0.0)));
+
+ let root_transform = LegacyIron3dEulerTransform {
+ translation: draft.position,
+ orientation_radians: draft.orientation_raw,
+ };
+ let root_from_body = root_transform
+ .try_row_major()
+ .expect("finite PLANT root matrix");
+ let world_door = BODY_DOOR.map(|point| {
+ root_transform
+ .try_transform_scaled_point(point, draft.scale)
+ .expect("finite BODY_DOOR world point")
+ });
+ let closed_nodes =
+ nodes_for_hierarchy(model, &closed_hierarchy.poses, root_from_body, draft.scale);
+ let closed = query_building_segment_world(model, &closed_nodes, world_door)
+ .expect("closed-door building query")
+ .expect("authored time-0 doorway blocker");
+ assert_eq!(closed.node, NodeId(3));
+ assert_eq!(closed.slot, SlotId(25));
+ assert_eq!(closed.batch_index, 100);
+ assert_eq!(closed.triangle_descriptor_index, 855);
+ assert_eq!(model.batches[closed.batch_index].batch_flags, 0x2000);
+
+ node_times[3] = Some(AnimationTime(node3_binding.frame_b));
+ let open_hierarchy = node38_sampled_hierarchy_at_times(model, &node_times)
+ .expect("authored Node3 time-1 hierarchy");
+ let open_nodes =
+ nodes_for_hierarchy(model, &open_hierarchy.poses, root_from_body, draft.scale);
+ assert!(query_building_segment_world(model, &open_nodes, world_door)
+ .expect("time-1 building query")
+ .is_none());
+ }
+}
diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs
index 088b51f..20821fe 100644
--- a/crates/fparkan-script/src/lib.rs
+++ b/crates/fparkan-script/src/lib.rs
@@ -3,7 +3,7 @@
//!
//! This module preserves the layout proven by the GOG `ai.dll` reader. It
//! deliberately does not implement a general bytecode VM. It contains a
-//! narrow, handler-specific resolver for the confirmed `Init` path.
+//! narrow resolver for confirmed `Init` handlers and bounded FML expressions.
use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits};
use std::sync::Arc;
@@ -40,7 +40,7 @@ pub enum VarSetType {
Dword,
}
-/// A lossless-in-meaning numeric default from `varset.var`.
+/// A numeric cell value that preserves the declared `varset.var` type.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VarSetDefault {
/// IEEE-754 bits parsed from a `float` literal.
@@ -151,33 +151,155 @@ pub struct Handler19DwordWrite {
pub value: u32,
}
-/// Result of resolving the first event whose case-sensitive C-string name is
-/// `Init` (bytes through the first NUL) with the supported
-/// `Handler(19)` action subset.
+/// A typed variable write emitted by the recovered first-`Init` subset.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct ScriptInitWrite {
+ /// Compiled varset declaration index.
+ pub index: u32,
+ /// Value in the original cell's numeric representation.
+ pub value: VarSetDefault,
+}
+
+/// Result of resolving the first case-sensitive `Init` event.
#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum Handler19InitResolution {
- /// No event has the case-sensitive C-string name `Init`; the native lookup
- /// makes no call.
+pub enum ScriptInitResolution {
+ /// No event has the case-sensitive C-string name `Init`.
Missing,
- /// The first `Init\0` event was walked in source order.
+ /// The first `Init` event completed within the recovered subset.
Executed {
- /// Zero-based event table index selected by the native name lookup.
+ /// Zero-based selected event index.
event_index: usize,
- /// Opaque event word retained from the selected record.
+ /// Opaque event word retained from the package.
event_word: u32,
- /// Ordered writes emitted by every supported instruction in the event.
- writes: Vec<Handler19DwordWrite>,
+ /// Ordered, typed writes emitted by the event.
+ writes: Vec<ScriptInitWrite>,
},
- /// The first unsupported instruction stopped this restricted resolver.
- /// Any earlier local writes are discarded because the event did not finish.
+ /// The event reached a known but unsupported form; earlier writes are discarded.
Unsupported {
- /// Zero-based event table index selected by the native name lookup.
+ /// Zero-based selected event index.
event_index: usize,
- /// Zero-based instruction index within that event.
+ /// Zero-based unsupported instruction index.
instruction_index: usize,
- /// Unsupported action shape.
- error: Handler19ResolveError,
+ /// Unsupported instruction or expression form.
+ error: ScriptInitResolveError,
+ },
+}
+
+/// A typed failure or unsupported form in the recovered `Init` subset.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum ScriptInitResolveError {
+ /// The selector is outside the recovered `Handler(19)` and formula assignment paths.
+ UnsupportedSelector {
+ /// Observed selector.
+ found: ScriptDispatchSelector,
+ },
+ /// A `Handler(19)` reference or declaration did not match its recovered contract.
+ Handler19(Handler19ResolveError),
+ /// A formula record has an unsupported control word.
+ UnsupportedFormulaControlWord {
+ /// Raw disk word at index 3.
+ found: u32,
+ },
+ /// A formula record requests the separate whole-varset-record copy path.
+ UnsupportedFormulaCopySource {
+ /// Raw disk word at index 2.
+ found: u32,
+ },
+ /// The selected script base did not provide an FML table.
+ MissingFormulaTable,
+ /// The FML ordinal is outside the preserved row table.
+ FormulaIndexOutOfBounds {
+ /// Zero-based FML row index.
+ index: u32,
+ /// Number of preserved FML rows.
+ rows: usize,
},
+ /// The referenced FML row is outside the supported atom-expression subset.
+ UnsupportedFormulaExpression {
+ /// Zero-based FML row index.
+ index: u32,
+ /// Raw expression bytes from the row.
+ expression: Vec<u8>,
+ },
+ /// A DWORD target cannot represent the native truncating qword conversion.
+ DwordConversionOutOfRange {
+ /// Compiled target index.
+ index: u32,
+ /// Evaluated binary32 payload.
+ value_bits: u32,
+ },
+ /// The formula record target is outside the selected varset.
+ VarSetIndexOutOfBounds {
+ /// Compiled target index.
+ index: u32,
+ /// Available declaration count.
+ declarations: usize,
+ },
+}
+
+impl std::fmt::Display for ScriptInitResolveError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::UnsupportedSelector { found } => {
+ write!(formatter, "unsupported script selector {found:?} in Init")
+ }
+ Self::Handler19(source) => write!(formatter, "Handler(19): {source}"),
+ Self::UnsupportedFormulaControlWord { found } => write!(
+ formatter,
+ "unsupported formula control word 0x{found:08x}; expected 0xffffffff"
+ ),
+ Self::UnsupportedFormulaCopySource { found } => write!(
+ formatter,
+ "unsupported formula source 0x{found:08x}; whole-varset copy is not modeled"
+ ),
+ Self::MissingFormulaTable => write!(formatter, "formula assignment has no FML table"),
+ Self::FormulaIndexOutOfBounds { index, rows } => write!(
+ formatter,
+ "formula index {index} is outside the FML table with {rows} rows"
+ ),
+ Self::UnsupportedFormulaExpression { index, expression } => write!(
+ formatter,
+ "formula {index} expression {:?} is outside the supported atom and binary +/− subset",
+ String::from_utf8_lossy(expression)
+ ),
+ Self::DwordConversionOutOfRange { index, value_bits } => write!(
+ formatter,
+ "formula value 0x{value_bits:08x} cannot be converted to DWORD target {index}"
+ ),
+ Self::VarSetIndexOutOfBounds {
+ index,
+ declarations,
+ } => write!(
+ formatter,
+ "formula target {index} is outside {declarations} varset declarations"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for ScriptInitResolveError {}
+
+enum InitInstructionResolution {
+ Executed(Vec<ScriptInitWrite>),
+ Unsupported(ScriptInitResolveError),
+}
+
+/// Rows loaded from one script base's `.fml` file in native table order.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct FormulaTable {
+ /// Accepted `FUNCTION` rows in their original order, including unsupported expressions.
+ pub rows: Vec<FormulaRow>,
+}
+
+/// One source row accepted by the native FML line loader.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FormulaRow {
+ /// Name token retained from the first comma-delimited field.
+ pub name_raw: Vec<u8>,
+ /// Expression bytes from the second field; interpretation is lazy.
+ pub expression_raw: Vec<u8>,
+ /// Final field bytes, retained without interpreting their meaning.
+ pub trailing_raw: Vec<u8>,
}
/// Inputs already expressed at the original `Handler(19)` setter ABI boundary.
@@ -205,12 +327,12 @@ pub enum Handler19ResolveError {
},
/// Only action instructions with control word `0xffff_ffff` are supported.
UnsupportedControlWord {
- /// Opaque control word from instruction header word one.
+ /// Opaque control word from disk-order header index 3.
found: u32,
},
/// Only instructions with no result-save reference are supported.
UnsupportedResultSave {
- /// Opaque variable reference from instruction header word two.
+ /// Opaque variable reference from disk-order header index 1.
found: u32,
},
/// One of the three required references was absent.
@@ -583,95 +705,220 @@ impl VarSet {
])
}
- /// Resolves only the first event whose case-sensitive C-string name is
- /// `Init` (bytes through the first NUL), and its supported
- /// `Handler(19)` action instructions in source order.
+ fn resolve_handler19_target(
+ &self,
+ instruction: &ScriptInstruction,
+ position: usize,
+ ) -> Result<u32, Handler19ResolveError> {
+ let index = *instruction
+ .references
+ .get(position)
+ .ok_or(Handler19ResolveError::MissingReference { position })?;
+ match self.declarations.get(index as usize) {
+ Some(VarSetDeclaration {
+ type_name: VarSetType::Dword,
+ ..
+ }) => Ok(index),
+ Some(declaration) => Err(Handler19ResolveError::UnexpectedType {
+ position,
+ found: declaration.type_name,
+ }),
+ None => Err(Handler19ResolveError::VarSetIndexOutOfBounds {
+ index,
+ declarations: self.declarations.len(),
+ }),
+ }
+ }
+
+ /// Resolves the first case-sensitive `Init` event's recovered write subset.
+ ///
+ /// Handler(19) writes use the caller's original setter-boundary words. A
+ /// sentinel selector is accepted only for the proven formula assignment
+ /// with control and copy-source words both equal to `0xffffffff`; the
+ /// referenced row is evaluated only when that instruction is reached.
+ /// Formula atoms read defaults and earlier successful writes in this event.
+ ///
+ /// # Errors
///
- /// A missing event is a normal no-write result, matching the native event
- /// lookup. Unsupported selector/control/result-save forms stop at their
- /// first instruction and return no partial writes. Malformed handler
- /// references and declaration types remain errors.
- pub fn resolve_handler19_init(
+ /// Returns a typed error for malformed Handler(19) references, invalid
+ /// formula or varset indices, and targets outside the selected varset.
+ pub fn resolve_init(
&self,
package: &ScriptPackage,
input: Handler19InitInput,
- ) -> Result<Handler19InitResolution, Handler19ResolveError> {
+ formulas: Option<&FormulaTable>,
+ ) -> Result<ScriptInitResolution, ScriptInitResolveError> {
let Some((event_index, event)) = package.events.iter().enumerate().find(|(_, event)| {
event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])
}) else {
- return Ok(Handler19InitResolution::Missing);
+ return Ok(ScriptInitResolution::Missing);
};
+ let mut values = self
+ .declarations
+ .iter()
+ .map(|declaration| declaration.default_value)
+ .collect::<Vec<_>>();
let mut writes = Vec::new();
for (instruction_index, instruction) in event.instructions.iter().enumerate() {
- let instruction_writes =
- match self.resolve_handler19_init_instruction(instruction, input) {
- Ok(writes) => writes,
- Err(error @ Handler19ResolveError::UnsupportedSelector { .. })
- | Err(error @ Handler19ResolveError::UnsupportedControlWord { .. })
- | Err(error @ Handler19ResolveError::UnsupportedResultSave { .. }) => {
- return Ok(Handler19InitResolution::Unsupported {
- event_index,
- instruction_index,
- error,
- });
+ match self.resolve_init_instruction(instruction, input, formulas, &values)? {
+ InitInstructionResolution::Executed(instruction_writes) => {
+ for write in &instruction_writes {
+ let Some(value) = values.get_mut(write.index as usize) else {
+ return Err(ScriptInitResolveError::VarSetIndexOutOfBounds {
+ index: write.index,
+ declarations: self.declarations.len(),
+ });
+ };
+ *value = write.value;
}
- Err(error) => return Err(error),
- };
- writes.extend(instruction_writes);
+ writes.extend(instruction_writes);
+ }
+ InitInstructionResolution::Unsupported(error) => {
+ return Ok(ScriptInitResolution::Unsupported {
+ event_index,
+ instruction_index,
+ error,
+ });
+ }
+ }
}
- Ok(Handler19InitResolution::Executed {
+ Ok(ScriptInitResolution::Executed {
event_index,
event_word: event.event_word,
writes,
})
}
- fn resolve_handler19_init_instruction(
+ fn resolve_init_instruction(
&self,
instruction: &ScriptInstruction,
input: Handler19InitInput,
- ) -> Result<[Handler19DwordWrite; 3], Handler19ResolveError> {
- let selector = instruction.dispatch_selector();
- if selector != ScriptDispatchSelector::Handler(19) {
- return Err(Handler19ResolveError::UnsupportedSelector { found: selector });
- }
- if instruction.header_words[1] != u32::MAX {
- return Err(Handler19ResolveError::UnsupportedControlWord {
- found: instruction.header_words[1],
- });
- }
- if instruction.header_words[2] != u32::MAX {
- return Err(Handler19ResolveError::UnsupportedResultSave {
- found: instruction.header_words[2],
- });
- }
- self.resolve_handler19(instruction, input)
- }
-
- fn resolve_handler19_target(
- &self,
- instruction: &ScriptInstruction,
- position: usize,
- ) -> Result<u32, Handler19ResolveError> {
- let index = *instruction
- .references
- .get(position)
- .ok_or(Handler19ResolveError::MissingReference { position })?;
- match self.declarations.get(index as usize) {
- Some(VarSetDeclaration {
- type_name: VarSetType::Dword,
- ..
- }) => Ok(index),
- Some(declaration) => Err(Handler19ResolveError::UnexpectedType {
- position,
- found: declaration.type_name,
- }),
- None => Err(Handler19ResolveError::VarSetIndexOutOfBounds {
- index,
- declarations: self.declarations.len(),
- }),
+ formulas: Option<&FormulaTable>,
+ values: &[VarSetDefault],
+ ) -> Result<InitInstructionResolution, ScriptInitResolveError> {
+ match instruction.dispatch_selector() {
+ ScriptDispatchSelector::Handler(19) => {
+ if instruction.header_words[3] != u32::MAX {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::Handler19(
+ Handler19ResolveError::UnsupportedControlWord {
+ found: instruction.header_words[3],
+ },
+ ),
+ ));
+ }
+ if instruction.header_words[1] != u32::MAX {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::Handler19(
+ Handler19ResolveError::UnsupportedResultSave {
+ found: instruction.header_words[1],
+ },
+ ),
+ ));
+ }
+ match self.resolve_handler19(instruction, input) {
+ Ok(writes) => Ok(InitInstructionResolution::Executed(
+ writes
+ .into_iter()
+ .map(|write| ScriptInitWrite {
+ index: write.index,
+ value: VarSetDefault::Dword(write.value),
+ })
+ .collect(),
+ )),
+ Err(error @ Handler19ResolveError::UnsupportedControlWord { .. })
+ | Err(error @ Handler19ResolveError::UnsupportedResultSave { .. }) => {
+ Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::Handler19(error),
+ ))
+ }
+ Err(error) => Err(ScriptInitResolveError::Handler19(error)),
+ }
+ }
+ ScriptDispatchSelector::Sentinel => {
+ if instruction.header_words[3] != u32::MAX {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaControlWord {
+ found: instruction.header_words[3],
+ },
+ ));
+ }
+ if instruction.header_words[2] != u32::MAX {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaCopySource {
+ found: instruction.header_words[2],
+ },
+ ));
+ }
+ let Some(formulas) = formulas else {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::MissingFormulaTable,
+ ));
+ };
+ let formula_index = instruction.header_words[6];
+ let Some(formula) = formulas.rows.get(formula_index as usize) else {
+ return Err(ScriptInitResolveError::FormulaIndexOutOfBounds {
+ index: formula_index,
+ rows: formulas.rows.len(),
+ });
+ };
+ let Some(value) = parse_init_formula_expression(
+ &formula.expression_raw,
+ &self.declarations,
+ values,
+ ) else {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: formula_index,
+ expression: formula.expression_raw.clone(),
+ },
+ ));
+ };
+ let target_index = instruction.header_words[1];
+ let Some(target) = self.declarations.get(target_index as usize) else {
+ return Err(ScriptInitResolveError::VarSetIndexOutOfBounds {
+ index: target_index,
+ declarations: self.declarations.len(),
+ });
+ };
+ let Some(current_target) = values.get(target_index as usize) else {
+ return Err(ScriptInitResolveError::VarSetIndexOutOfBounds {
+ index: target_index,
+ declarations: values.len(),
+ });
+ };
+ if !varset_value_matches_type(target.type_name, *current_target) {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: formula_index,
+ expression: formula.expression_raw.clone(),
+ },
+ ));
+ }
+ let value = match target.type_name {
+ VarSetType::Float => VarSetDefault::FloatBits(value.to_bits()),
+ VarSetType::Dword => {
+ let Some(value) = formula_dword_value(value) else {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::DwordConversionOutOfRange {
+ index: target_index,
+ value_bits: value.to_bits(),
+ },
+ ));
+ };
+ VarSetDefault::Dword(value)
+ }
+ };
+ Ok(InitInstructionResolution::Executed(vec![ScriptInitWrite {
+ index: target_index,
+ value,
+ }]))
+ }
+ found => Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedSelector { found },
+ )),
}
}
@@ -870,6 +1117,393 @@ impl std::fmt::Display for VarSetError {
impl std::error::Error for VarSetError {}
+/// A failure evaluating one row as a single native numeric literal.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum FormulaLiteralError {
+ /// The referenced ordinal was outside the preserved table.
+ IndexOutOfBounds {
+ /// Zero-based row index.
+ index: u32,
+ /// Number of accepted rows.
+ rows: usize,
+ },
+ /// The row's expression was not one numeric token.
+ UnsupportedExpression {
+ /// Zero-based row index.
+ index: u32,
+ /// Raw expression bytes.
+ expression: Vec<u8>,
+ },
+}
+
+impl std::fmt::Display for FormulaLiteralError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::IndexOutOfBounds { index, rows } => {
+ write!(formatter, "formula index {index} is outside {rows} rows")
+ }
+ Self::UnsupportedExpression { index, expression } => write!(
+ formatter,
+ "formula {index} expression {:?} is not one numeric literal",
+ String::from_utf8_lossy(expression)
+ ),
+ }
+ }
+}
+
+impl std::error::Error for FormulaLiteralError {}
+
+impl FormulaTable {
+ /// Parses native `FUNCTION` rows without compiling their expressions.
+ ///
+ /// Input is read as text: CRLF becomes LF, Ctrl-Z ends input, and each
+ /// 255-byte read chunk is parsed independently. Accepted rows stay in
+ /// source order so formula references retain their original ordinals.
+ pub fn parse(bytes: &[u8]) -> Self {
+ let mut text = Vec::with_capacity(bytes.len());
+ let mut offset = 0;
+ while let Some(&byte) = bytes.get(offset) {
+ if byte == 0x1a {
+ break;
+ }
+ if byte == b'\r' && bytes.get(offset + 1) == Some(&b'\n') {
+ text.push(b'\n');
+ offset += 2;
+ } else {
+ text.push(byte);
+ offset += 1;
+ }
+ }
+
+ let mut table = Self::default();
+ let mut offset = 0;
+ while offset < text.len() {
+ let limit = (offset + 255).min(text.len());
+ let end = text[offset..limit]
+ .iter()
+ .position(|byte| *byte == b'\n')
+ .map_or(limit, |newline| offset + newline + 1);
+ parse_formula_chunk(&mut table, &text[offset..end]);
+ offset = end;
+ }
+ table
+ }
+
+ /// Evaluates a preserved row only when it is referenced by a formula assignment.
+ ///
+ /// The lexer behavior is bounded to the original number-token rules. The
+ /// converter consumes the longest decimal prefix, converts it to `f64`,
+ /// then rounds to the node's binary32 payload.
+ ///
+ /// # Errors
+ ///
+ /// Returns a typed error for an out-of-range ordinal or an expression
+ /// containing more than one supported numeric token.
+ pub fn evaluate_numeric_literal(&self, index: u32) -> Result<f32, FormulaLiteralError> {
+ let Some(row) = self.rows.get(index as usize) else {
+ return Err(FormulaLiteralError::IndexOutOfBounds {
+ index,
+ rows: self.rows.len(),
+ });
+ };
+ parse_native_numeric_literal(&row.expression_raw).ok_or_else(|| {
+ FormulaLiteralError::UnsupportedExpression {
+ index,
+ expression: row.expression_raw.clone(),
+ }
+ })
+ }
+}
+
+fn parse_formula_chunk(table: &mut FormulaTable, chunk: &[u8]) {
+ let visible_len = chunk
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(chunk.len());
+ let visible = &chunk[..visible_len];
+ let first_token = native_first_token(visible);
+ if first_token.iter().take(2).any(|byte| *byte == b'/') {
+ return;
+ }
+ if !visible
+ .windows(b"FUNCTION".len())
+ .any(|part| part == b"FUNCTION")
+ {
+ return;
+ }
+ let Some(open) = visible.iter().position(|byte| *byte == b'(') else {
+ return;
+ };
+ let first_comma = visible.iter().position(|byte| *byte == b',');
+ let second_comma = first_comma.and_then(|first| {
+ visible[first + 1..]
+ .iter()
+ .position(|byte| *byte == b',')
+ .map(|next| first + 1 + next)
+ });
+ let mut buffer = visible.to_vec();
+ buffer.push(0);
+ for byte in &mut buffer[..visible_len] {
+ if *byte == b',' {
+ *byte = 0;
+ }
+ }
+
+ let name_start = open + 1;
+ let expression_start = first_comma.map(|comma| comma + 1);
+ let trailing_start = second_comma.map(|comma| comma + 1);
+ let last_field_start = trailing_start.or(expression_start).unwrap_or(name_start);
+ let last_field_end = c_string_end(&buffer, last_field_start);
+ if let Some(close) = buffer[last_field_start..last_field_end]
+ .iter()
+ .rposition(|byte| *byte == b')')
+ {
+ buffer[last_field_start + close] = 0;
+ }
+
+ let name_field = c_string(&buffer, name_start);
+ if name_field.is_empty() {
+ return;
+ }
+ let expression_field = expression_start.map_or(&[][..], |start| c_string(&buffer, start));
+ let trailing_field = trailing_start.map_or(&[][..], |start| c_string(&buffer, start));
+ let name_token = native_first_token(name_field);
+ let name_raw = if name_token.is_empty() {
+ b"no_name".to_vec()
+ } else {
+ name_token.to_vec()
+ };
+ table.rows.push(FormulaRow {
+ name_raw,
+ expression_raw: expression_field.to_vec(),
+ trailing_raw: trailing_field.to_vec(),
+ });
+}
+
+fn c_string_end(buffer: &[u8], start: usize) -> usize {
+ buffer[start..]
+ .iter()
+ .position(|byte| *byte == 0)
+ .map_or(buffer.len(), |offset| start + offset)
+}
+
+fn c_string(buffer: &[u8], start: usize) -> &[u8] {
+ &buffer[start..c_string_end(buffer, start)]
+}
+
+fn native_first_token(field: &[u8]) -> &[u8] {
+ let start = field
+ .iter()
+ .position(|byte| *byte == 0 || *byte > 32)
+ .unwrap_or(field.len());
+ let end = field[start..]
+ .iter()
+ .position(|byte| *byte == 0 || *byte <= 32)
+ .map_or(field.len(), |offset| start + offset);
+ &field[start..end]
+}
+
+fn parse_native_numeric_literal(expression: &[u8]) -> Option<f32> {
+ let start = expression
+ .iter()
+ .position(|byte| *byte == 0 || *byte > 32)
+ .unwrap_or(expression.len());
+ let expression = &expression[start..];
+ let token_len = native_numeric_token_len(expression)?;
+ if expression[token_len..]
+ .iter()
+ .any(|byte| *byte != 0 && *byte > 32)
+ {
+ return None;
+ }
+ let converted_len = native_decimal_prefix_len(&expression[..token_len])?;
+ let prefix = std::str::from_utf8(&expression[..converted_len]).ok()?;
+ prefix.parse::<f64>().ok().map(|value| value as f32)
+}
+
+fn native_numeric_token_len(expression: &[u8]) -> Option<usize> {
+ if !expression.first().is_some_and(u8::is_ascii_digit) {
+ return None;
+ }
+ let mut index = 0;
+ let mut dot_allowed = true;
+ let mut exponent_sign_allowed = false;
+ while let Some(&byte) = expression.get(index) {
+ if byte.is_ascii_digit() {
+ exponent_sign_allowed = false;
+ index += 1;
+ } else if byte == b'.' && dot_allowed {
+ dot_allowed = false;
+ exponent_sign_allowed = false;
+ index += 1;
+ } else if matches!(byte, b'e' | b'E') {
+ dot_allowed = true;
+ exponent_sign_allowed = true;
+ index += 1;
+ } else if matches!(byte, b'+' | b'-') && exponent_sign_allowed {
+ exponent_sign_allowed = false;
+ index += 1;
+ } else {
+ break;
+ }
+ }
+ Some(index)
+}
+
+fn native_decimal_prefix_len(token: &[u8]) -> Option<usize> {
+ let mut index = 0;
+ while token.get(index).is_some_and(u8::is_ascii_digit) {
+ index += 1;
+ }
+ if token.get(index) == Some(&b'.') {
+ index += 1;
+ while token.get(index).is_some_and(u8::is_ascii_digit) {
+ index += 1;
+ }
+ }
+ if index == 0 {
+ return None;
+ }
+
+ if token
+ .get(index)
+ .is_some_and(|byte| matches!(byte, b'e' | b'E'))
+ {
+ let exponent_start = index;
+ index += 1;
+ if token
+ .get(index)
+ .is_some_and(|byte| matches!(byte, b'+' | b'-'))
+ {
+ index += 1;
+ }
+ let digits_start = index;
+ while token.get(index).is_some_and(u8::is_ascii_digit) {
+ index += 1;
+ }
+ if digits_start == index {
+ index = exponent_start;
+ }
+ }
+ Some(index)
+}
+
+fn parse_init_formula_expression(
+ expression: &[u8],
+ declarations: &[VarSetDeclaration],
+ values: &[VarSetDefault],
+) -> Option<f32> {
+ let (left, left_end) = parse_init_formula_atom(expression, 0, declarations, values)?;
+ let operator_index = skip_native_formula_space(expression, left_end);
+ let Some(&operator @ (b'+' | b'-')) = expression.get(operator_index) else {
+ return expression[left_end..]
+ .iter()
+ .all(|byte| *byte == 0 || *byte <= 32)
+ .then_some(left);
+ };
+ let (right, right_end) =
+ parse_init_formula_atom(expression, operator_index + 1, declarations, values)?;
+ if !expression[right_end..]
+ .iter()
+ .all(|byte| *byte == 0 || *byte <= 32)
+ {
+ return None;
+ }
+ Some(match operator {
+ b'+' => right + left,
+ b'-' => left - right,
+ _ => unreachable!(),
+ })
+}
+
+fn parse_init_formula_atom(
+ expression: &[u8],
+ start: usize,
+ declarations: &[VarSetDeclaration],
+ values: &[VarSetDefault],
+) -> Option<(f32, usize)> {
+ let start = skip_native_formula_space(expression, start);
+ let remaining = expression.get(start..)?;
+ let first = *remaining.first()?;
+ let token_len = if first.is_ascii_digit() {
+ native_numeric_token_len(remaining)?
+ } else {
+ native_variable_token_len(remaining)?
+ };
+ let token = &remaining[..token_len];
+ if is_native_operator_atom(token) {
+ return None;
+ }
+
+ let value = if let Some(index) = declarations
+ .iter()
+ .position(|declaration| declaration.name.as_bytes() == token)
+ {
+ let declaration = &declarations[index];
+ let value = *values.get(index)?;
+ varset_value_as_f32(declaration.type_name, value)?
+ } else {
+ parse_native_numeric_literal(token)?
+ };
+ Some((value, start + token_len))
+}
+
+fn native_variable_token_len(token: &[u8]) -> Option<usize> {
+ let first = *token.first()?;
+ if first == b'^' || !is_native_variable_byte(first) {
+ return None;
+ }
+ Some(
+ token
+ .iter()
+ .take_while(|byte| is_native_variable_byte(**byte))
+ .count(),
+ )
+}
+
+fn is_native_variable_byte(byte: u8) -> bool {
+ byte.is_ascii() && ((byte > b'@' && byte < b'{') || byte.is_ascii_digit() || byte == b'_')
+}
+
+fn is_native_operator_atom(token: &[u8]) -> bool {
+ matches!(
+ token,
+ b"+" | b"-" | b"*" | b"/" | b"^" | b"&" | b"|" | b"!" | b"A" | b"B" | b"N" | b"S"
+ )
+}
+
+fn skip_native_formula_space(expression: &[u8], start: usize) -> usize {
+ expression[start..]
+ .iter()
+ .position(|byte| *byte == 0 || *byte > 32)
+ .map_or(expression.len(), |offset| start + offset)
+}
+
+fn varset_value_as_f32(type_name: VarSetType, value: VarSetDefault) -> Option<f32> {
+ match (type_name, value) {
+ (VarSetType::Float, VarSetDefault::FloatBits(bits)) => Some(f32::from_bits(bits)),
+ (VarSetType::Dword, VarSetDefault::Dword(value)) => Some(value as f32),
+ _ => None,
+ }
+}
+
+fn varset_value_matches_type(type_name: VarSetType, value: VarSetDefault) -> bool {
+ matches!(
+ (type_name, value),
+ (VarSetType::Float, VarSetDefault::FloatBits(_))
+ | (VarSetType::Dword, VarSetDefault::Dword(_))
+ )
+}
+
+fn formula_dword_value(value: f32) -> Option<u32> {
+ const SIGNED_QWORD_LIMIT: f64 = 9_223_372_036_854_775_808.0;
+ let value = f64::from(value);
+ if !value.is_finite() || value < -SIGNED_QWORD_LIMIT || value >= SIGNED_QWORD_LIMIT {
+ return None;
+ }
+ Some(value.trunc() as i64 as u32)
+}
+
/// A compiled `.scr` package.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScriptPackage {
@@ -1317,15 +1951,58 @@ fn read_instruction(
#[cfg(test)]
mod tests {
use super::{
- decode, decode_with_limits, parse_varset, Handler15ResolveError, Handler15TargetPayload,
- Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
+ decode, decode_with_limits, parse_varset, FormulaTable, Handler15ResolveError,
+ Handler15TargetPayload, Handler19DwordWrite, Handler19InitInput, Handler19ResolveError,
Handler2RecordInput, Handler2RecordScheduler, Handler30ResolveError, Handler8Reset,
- Handler8ResolveError, ScriptDispatchSelector, ScriptEvent, ScriptInstruction,
- ScriptPackage, VarSetDefault, VarSetError, VarSetType, VmHostCallbackCommand,
- GOG_HANDLER_COUNT, INSTRUCTION_WORDS,
+ Handler8ResolveError, ScriptDispatchSelector, ScriptEvent, ScriptInitResolution,
+ ScriptInitResolveError, ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSetDefault,
+ VarSetError, VarSetType, VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS,
};
use fparkan_binary::{DecodeError, Limits};
+ fn init_package(instructions: Vec<ScriptInstruction>) -> ScriptPackage {
+ ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions,
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ }
+ }
+
+ fn init_formula_assignment(target: u32, formula: u32) -> ScriptInstruction {
+ ScriptInstruction {
+ header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, formula],
+ references: Vec::new(),
+ }
+ }
+
+ fn init_handler19() -> ScriptInstruction {
+ ScriptInstruction {
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
+ references: vec![0, 1, 2],
+ }
+ }
+
+ fn init_input() -> Handler19InitInput {
+ Handler19InitInput {
+ first_x87_word: 10,
+ second_x87_word: 20,
+ third_word: 30,
+ }
+ }
+
+ fn one_formula(expression: &[u8]) -> FormulaTable {
+ let mut source = b"FUNCTION(test, ".to_vec();
+ source.extend_from_slice(expression);
+ source.extend_from_slice(b", )\n");
+ FormulaTable::parse(&source)
+ }
+
fn handler_two_input(
scalar_1: f32,
scalar_4: f32,
@@ -1470,6 +2147,73 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
+ fn formula_table_preserves_native_rows_and_field_defaults() {
+ let table = FormulaTable::parse(
+ b"// a comment\r\nFUNCTION(no_fields)\r\nFUNCTION(name,0)\r\nFUNCTION(name,0,)\r\nFUNCTION( , 2, )\r\nFUNCTION(, 3,)\r\nFUNCTION missing_parenthesis,4,\r\nFUNCTION()\r\n/x FUNCTION(ignored,5,)\r\nx/FUNCTION(ignored,6,)\r\nx,FUNCTION(name,0,)\r\nx,FUNCTION(name)\r\n",
+ );
+
+ assert_eq!(table.rows.len(), 6);
+ assert_eq!(table.rows[0].name_raw, b"no_fields");
+ assert!(table.rows[0].expression_raw.is_empty());
+ assert_eq!(table.rows[1].name_raw, b"name");
+ assert_eq!(table.rows[1].expression_raw, b"0");
+ assert!(table.rows[1].trailing_raw.is_empty());
+ assert_eq!(table.rows[2].expression_raw, b"0");
+ assert!(table.rows[2].trailing_raw.is_empty());
+ assert_eq!(table.rows[3].name_raw, b"no_name");
+ assert_eq!(table.rows[3].expression_raw, b" 2");
+ assert_eq!(table.rows[4].name_raw, b"name");
+ assert_eq!(table.rows[4].expression_raw, b"FUNCTION(name");
+ assert_eq!(table.rows[4].trailing_raw, b"0");
+ assert_eq!(table.rows[5].name_raw, b"name");
+ assert_eq!(table.rows[5].expression_raw, b"FUNCTION(name");
+ assert!(table.rows[5].trailing_raw.is_empty());
+ }
+
+ #[test]
+ fn formula_table_chunks_without_reassembling_and_stops_at_text_eof() {
+ let mut source = vec![b' '; 255];
+ source.extend_from_slice(b"FUNCTION(before, 1, )\r\n");
+ source.extend_from_slice(b"FUNCTION(after, 2, )\r\n");
+ source.push(0x1a);
+ source.extend_from_slice(b"FUNCTION(ignored, 3, )\r\n");
+
+ let table = FormulaTable::parse(&source);
+ assert_eq!(table.rows.len(), 2);
+ assert_eq!(table.rows[0].name_raw, b"before");
+ assert_eq!(table.rows[1].name_raw, b"after");
+ }
+
+ #[test]
+ fn formula_table_uses_only_the_nul_terminated_view_of_each_chunk() {
+ let table = FormulaTable::parse(
+ b"FUNCTION(visible, 1, )\0 FUNCTION(hidden, 2, )\nFUNCTION(next, 3, )\n",
+ );
+ assert_eq!(table.rows.len(), 2);
+ assert_eq!(table.rows[0].name_raw, b"visible");
+ assert_eq!(table.rows[1].name_raw, b"next");
+ }
+
+ #[test]
+ fn formula_literal_evaluation_uses_the_native_numeric_prefix() {
+ let table = FormulaTable::parse(
+ b"FUNCTION(scientific,1e-2,)\nFUNCTION(integer_dot,1.,)\nFUNCTION(exp_prefix,1e2.3,)\nFUNCTION(incomplete,1e+,)\nFUNCTION(unary,-1,)\nFUNCTION(operator,1+2,)\n",
+ );
+
+ for (index, expected) in [(0, 0.01_f32), (1, 1.0), (2, 100.0), (3, 1.0)] {
+ assert_eq!(
+ table
+ .evaluate_numeric_literal(index)
+ .expect("native numeric token"),
+ expected
+ );
+ }
+ assert!(table.evaluate_numeric_literal(4).is_err());
+ assert!(table.evaluate_numeric_literal(5).is_err());
+ assert!(table.evaluate_numeric_literal(6).is_err());
+ }
+
+ #[test]
fn handler_thirty_resolves_only_proven_dword_operands() {
let varset = parse_varset(
b"VAR( float, f0, 0.5)\nVAR( DWORD, first, 0x12)\nVAR( DWORD, second, 9)\n",
@@ -1684,11 +2428,11 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
- fn handler_nineteen_init_uses_first_exact_event_and_keeps_instruction_order() {
+ fn script_init_uses_first_exact_event_and_keeps_instruction_order() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let action = |references: Vec<u32>| ScriptInstruction {
- header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
references,
};
let package = ScriptPackage {
@@ -1715,34 +2459,53 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
let result = varset
- .resolve_handler19_init(
+ .resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
)
.expect("valid first Init event");
assert_eq!(
result,
- Handler19InitResolution::Executed {
+ ScriptInitResolution::Executed {
event_index: 0,
event_word: 0x1234,
writes: vec![
- Handler19DwordWrite { index: 0, value: 7 },
- Handler19DwordWrite { index: 1, value: 8 },
- Handler19DwordWrite { index: 2, value: 9 },
- Handler19DwordWrite { index: 1, value: 7 },
- Handler19DwordWrite { index: 0, value: 8 },
- Handler19DwordWrite { index: 2, value: 9 },
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(7),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(8),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(9),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(7),
+ },
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(8),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(9),
+ },
],
}
);
}
#[test]
- fn handler_nineteen_init_missing_exact_name_has_no_writes() {
+ fn script_init_missing_exact_name_has_no_writes() {
let varset = parse_varset(b"").expect("empty varset");
let package = ScriptPackage {
opcode_handler_count: 73,
@@ -1765,20 +2528,21 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
assert_eq!(
- varset.resolve_handler19_init(
+ varset.resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
),
- Ok(Handler19InitResolution::Missing)
+ Ok(ScriptInitResolution::Missing)
);
}
#[test]
- fn handler_nineteen_init_accepts_empty_first_event_as_executed_with_no_writes() {
+ fn script_init_accepts_empty_first_event_as_executed_with_no_writes() {
let varset = parse_varset(b"").expect("empty varset");
let package = ScriptPackage {
opcode_handler_count: 73,
@@ -1804,15 +2568,16 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
assert_eq!(
- varset.resolve_handler19_init(
+ varset.resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
),
- Ok(Handler19InitResolution::Executed {
+ Ok(ScriptInitResolution::Executed {
event_index: 0,
event_word: 0x1234,
writes: Vec::new(),
@@ -1821,7 +2586,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
- fn handler_nineteen_init_reports_unsupported_selector_and_discards_prior_writes() {
+ fn script_init_reports_unsupported_selector_and_discards_prior_writes() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let package = ScriptPackage {
@@ -1832,7 +2597,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
event_word: 0,
instructions: vec![
ScriptInstruction {
- header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
references: vec![0, 1, 2],
},
ScriptInstruction {
@@ -1840,7 +2605,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
references: Vec::new(),
},
ScriptInstruction {
- header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
references: vec![0, 1, 2],
},
],
@@ -1850,18 +2615,19 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
assert_eq!(
- varset.resolve_handler19_init(
+ varset.resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
),
- Ok(Handler19InitResolution::Unsupported {
+ Ok(ScriptInitResolution::Unsupported {
event_index: 0,
instruction_index: 1,
- error: Handler19ResolveError::UnsupportedSelector {
+ error: ScriptInitResolveError::UnsupportedSelector {
found: super::ScriptDispatchSelector::Handler(18),
},
})
@@ -1869,7 +2635,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
- fn handler_nineteen_init_stops_at_unsupported_selector_before_any_write() {
+ fn script_init_stops_at_unsupported_selector_before_any_write() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let package = ScriptPackage {
@@ -1884,7 +2650,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
references: Vec::new(),
},
ScriptInstruction {
- header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
references: vec![0, 1, 2],
},
],
@@ -1894,18 +2660,19 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
assert_eq!(
- varset.resolve_handler19_init(
+ varset.resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
),
- Ok(Handler19InitResolution::Unsupported {
+ Ok(ScriptInitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
- error: Handler19ResolveError::UnsupportedSelector {
+ error: ScriptInitResolveError::UnsupportedSelector {
found: super::ScriptDispatchSelector::Handler(18),
},
})
@@ -1913,7 +2680,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
- fn handler_nineteen_init_types_unsupported_control_and_result_save() {
+ fn script_init_uses_handler_nineteen_control_and_result_fields() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let input = Handler19InitInput {
@@ -1921,41 +2688,484 @@ STRING( 8, ignored, ignored, ignored)\r\n";
second_x87_word: 8,
third_word: 9,
};
- let instruction = |control_word, result_word| ScriptInstruction {
- header_words: [19, control_word, result_word, 0, 0, 3, 0],
- references: vec![0, 1, 2],
- };
- let resolve = |instruction| {
+ let resolve = |header_words| {
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0,
- instructions: vec![instruction],
+ instructions: vec![ScriptInstruction {
+ header_words,
+ references: vec![0, 1, 2],
+ }],
}],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
- varset.resolve_handler19_init(&package, input)
+ varset.resolve_init(&package, input, None)
+ };
+
+ let actual = (
+ resolve([19, u32::MAX, u32::MAX, 2, 6, 3, u32::MAX]),
+ resolve([19, 0x1234, u32::MAX, u32::MAX, 6, 3, u32::MAX]),
+ resolve([19, u32::MAX, 0xdecafbad, u32::MAX, 6, 3, u32::MAX]),
+ );
+ assert_eq!(
+ actual,
+ (
+ Ok(ScriptInitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 0,
+ error: ScriptInitResolveError::Handler19(
+ Handler19ResolveError::UnsupportedControlWord { found: 2 },
+ ),
+ }),
+ Ok(ScriptInitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 0,
+ error: ScriptInitResolveError::Handler19(
+ Handler19ResolveError::UnsupportedResultSave { found: 0x1234 },
+ ),
+ }),
+ Ok(ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(7),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(8),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(9),
+ },
+ ],
+ }),
+ )
+ );
+ }
+
+ #[test]
+ fn script_init_assigns_numeric_literals_using_the_target_type() {
+ let varset = parse_varset(b"VAR(DWORD, integer, 0)\nVAR(float, scalar, 0)\n")
+ .expect("numeric declarations");
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![
+ ScriptInstruction {
+ header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 0xdead_beef, 0, 0],
+ references: Vec::new(),
+ },
+ ScriptInstruction {
+ header_words: [u32::MAX, 1, u32::MAX, u32::MAX, 0xdead_beef, 0, 1],
+ references: Vec::new(),
+ },
+ ],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+ let formulas = FormulaTable::parse(
+ b"FUNCTION(integer, 12.75, )\nFUNCTION(scalar, 0.8, )\nFUNCTION(unused, target + 2, )\n",
+ );
+
+ assert_eq!(
+ varset.resolve_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ ),
+ Ok(ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(12),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::FloatBits(0x3f4c_cccd),
+ },
+ ],
+ })
+ );
+ }
+
+ #[test]
+ fn script_init_formula_variables_read_prior_writes_and_self() {
+ let varset = parse_varset(
+ b"VAR(DWORD, source, 0)\nVAR(DWORD, y, 0)\nVAR(DWORD, id, 0)\nVAR(float, target, 0)\nVAR(DWORD, integer, 0)\n",
+ )
+ .expect("numeric declarations");
+ let formulas = FormulaTable::parse(
+ b"FUNCTION(literal, 7.0, )\nFUNCTION(add, source + 2.0, )\nFUNCTION(subtract, target - source, )\nFUNCTION(self, target + 1.0, )\nFUNCTION(integer, 10.75 + 0.5, )\nFUNCTION(exponent, target + 1e-2, )\n",
+ );
+ let package = init_package(vec![
+ init_handler19(),
+ init_formula_assignment(3, 0),
+ init_formula_assignment(3, 1),
+ init_formula_assignment(3, 2),
+ init_formula_assignment(3, 3),
+ init_formula_assignment(4, 4),
+ init_formula_assignment(3, 5),
+ ]);
+
+ assert_eq!(
+ varset.resolve_init(&package, init_input(), Some(&formulas),),
+ Ok(ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(10),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(20),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(30),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(7.0_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(12.0_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(2.0_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(3.0_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: 4,
+ value: VarSetDefault::Dword(11),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(3.01_f32.to_bits()),
+ },
+ ],
+ })
+ );
+ }
+
+ #[test]
+ fn script_init_formula_atoms_use_native_token_and_name_rules() {
+ let varset = parse_varset(
+ b"VAR(float, dup, 1)\nVAR(float, dup, 2)\nVAR(float, Name, 3)\nVAR(float, name, 4)\nVAR(float, 1e-2, 41)\nVAR(float, A, 8)\nVAR(float, A1, 9)\nVAR(DWORD, high, 4294967295)\nVAR(float, large, 16777216)\nVAR(float, target, 0)\nVAR(DWORD, integer, 0)\n",
+ )
+ .expect("numeric declarations");
+ let formulas = FormulaTable::parse(
+ b"FUNCTION(duplicate, dup + 0, )\nFUNCTION(upper, Name + 0, )\nFUNCTION(lower, name + 0, )\nFUNCTION(numeric_name, 1e-2 + 0, )\nFUNCTION(long_operator_prefix, A1 + 0, )\nFUNCTION(unsigned_dword, high + 0, )\nFUNCTION(negative_exponent, 5e-2 + 2e-2, )\nFUNCTION(positive_exponent, 1e+2 + 2e-2, )\nFUNCTION(binary32_rounding, 16777216 + 1, )\nFUNCTION(cancellation, target - 16777215, )\nFUNCTION(dword_target, 10.75 + 0.5, )\nFUNCTION(single_variable, large, )\nFUNCTION(negative_dword_wrap, 2 - 3, )\nFUNCTION(unsigned_dword_wrap, high + 0, )\n",
+ );
+ let package = init_package(
+ (0..14)
+ .map(|formula| {
+ init_formula_assignment(
+ if matches!(formula, 10 | 12 | 13) {
+ 10
+ } else {
+ 9
+ },
+ formula,
+ )
+ })
+ .collect(),
+ );
+
+ let resolved = varset
+ .resolve_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ )
+ .expect("resolve Init")
+ .clone();
+ assert_eq!(
+ resolved,
+ ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(1.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(3.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(4.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(41.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(9.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(4_294_967_296.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits((0.05_f32 + 0.02_f32).to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits((100.0_f32 + 0.02_f32).to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(16_777_216.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(1.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 10,
+ value: VarSetDefault::Dword(11)
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(16_777_216.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 10,
+ value: VarSetDefault::Dword(u32::MAX)
+ },
+ ScriptInitWrite {
+ index: 10,
+ value: VarSetDefault::Dword(0)
+ },
+ ],
+ }
+ );
+ assert!(formulas.evaluate_numeric_literal(0).is_err());
+ }
+
+ #[test]
+ fn script_init_rejects_unsupported_formula_variable_forms() {
+ let varset = parse_varset(
+ b"VAR(float, target, 1)\nVAR(float, A, 2)\nVAR(float, A1, 3)\nVAR(float, ^name, 4)\n",
+ )
+ .expect("numeric declarations");
+ for expression in [
+ &b"target * 2"[..],
+ b"-1",
+ b"target + 1 + 2",
+ b"missing + 1",
+ b"A + 1",
+ b"^name + 1",
+ b"name\xc3\xa9 + 1",
+ ] {
+ let formulas = one_formula(expression);
+ let package = init_package(vec![init_formula_assignment(0, 0)]);
+ assert!(
+ matches!(
+ varset.resolve_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ ),
+ Ok(ScriptInitResolution::Unsupported {
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 0,
+ ..
+ },
+ ..
+ })
+ ),
+ "accepted unsupported formula {:?}",
+ String::from_utf8_lossy(expression)
+ );
+ }
+
+ let mut inconsistent = varset;
+ inconsistent.declarations[0].default_value = VarSetDefault::Dword(1);
+ let formulas = one_formula(b"target + 1");
+ let package = init_package(vec![init_formula_assignment(0, 0)]);
+ assert!(matches!(
+ inconsistent.resolve_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ ),
+ Ok(ScriptInitResolution::Unsupported {
+ error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, .. },
+ ..
+ })
+ ));
+
+ let mut inconsistent_target =
+ parse_varset(b"VAR(float, target, 1)\n").expect("numeric declaration");
+ inconsistent_target.declarations[0].default_value = VarSetDefault::Dword(1);
+ let formulas = one_formula(b"2.0 + 1");
+ assert!(matches!(
+ inconsistent_target.resolve_init(
+ &init_package(vec![init_formula_assignment(0, 0)]),
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ ),
+ Ok(ScriptInitResolution::Unsupported {
+ error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, .. },
+ ..
+ })
+ ));
+ }
+
+ #[test]
+ fn script_init_keeps_unsupported_formula_expressions_lazy_and_transactional() {
+ let varset = parse_varset(b"VAR(DWORD, target, 0)\nVAR(DWORD, integer, 0)\n")
+ .expect("numeric declaration");
+ let formulas =
+ FormulaTable::parse(b"FUNCTION(unsupported, target * 2, )\nFUNCTION(literal, 7.5, )\n");
+ let action = |formula_index| ScriptInstruction {
+ header_words: [u32::MAX, 1, u32::MAX, u32::MAX, 6, 0, formula_index],
+ references: Vec::new(),
+ };
+ let package = |formula_index| ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![action(formula_index)],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+ let input = Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
};
assert_eq!(
- resolve(instruction(0, u32::MAX)),
- Ok(Handler19InitResolution::Unsupported {
+ varset.resolve_init(&package(1), input, Some(&formulas)),
+ Ok(ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(7),
+ }],
+ })
+ );
+ assert_eq!(
+ varset.resolve_init(&package(0), input, Some(&formulas)),
+ Ok(ScriptInitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
- error: Handler19ResolveError::UnsupportedControlWord { found: 0 },
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 0,
+ expression: b" target * 2".to_vec(),
+ },
})
);
+
+ let mut transactional = package(0);
+ transactional.events[0].instructions.insert(0, action(1));
assert_eq!(
- resolve(instruction(u32::MAX, 2)),
- Ok(Handler19InitResolution::Unsupported {
+ varset.resolve_init(&transactional, input, Some(&formulas)),
+ Ok(ScriptInitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 1,
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 0,
+ expression: b" target * 2".to_vec(),
+ },
+ })
+ );
+ }
+
+ #[test]
+ fn script_init_keeps_missing_table_and_bad_indices_typed() {
+ let varset = parse_varset(b"VAR(DWORD, integer, 0)\n").expect("numeric declaration");
+ let instruction = |target, formula_index| ScriptInstruction {
+ header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, formula_index],
+ references: Vec::new(),
+ };
+ let package = |target, formula_index| ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![instruction(target, formula_index)],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+ let input = Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ };
+ let formulas = FormulaTable::parse(b"FUNCTION(literal, 1, )\n");
+
+ assert_eq!(
+ varset.resolve_init(&package(0, 0), input, None),
+ Ok(ScriptInitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
- error: Handler19ResolveError::UnsupportedResultSave { found: 2 },
+ error: ScriptInitResolveError::MissingFormulaTable,
})
);
+ assert_eq!(
+ varset.resolve_init(&package(1, 0), input, Some(&formulas)),
+ Err(ScriptInitResolveError::VarSetIndexOutOfBounds {
+ index: 1,
+ declarations: 1,
+ })
+ );
+ assert_eq!(
+ varset.resolve_init(&package(0, 1), input, Some(&formulas)),
+ Err(ScriptInitResolveError::FormulaIndexOutOfBounds { index: 1, rows: 1 })
+ );
}
#[test]
diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs
index 54946e7..5e17a23 100644
--- a/crates/fparkan-terrain/src/lib.rs
+++ b/crates/fparkan-terrain/src/lib.rs
@@ -409,6 +409,13 @@ impl TerrainWorld {
self.surfaces.len()
}
+ /// Bounds of the indexed surface geometry, excluding unused source vertices.
+ #[must_use]
+ pub fn surface_bounds(&self) -> Option<[[f32; 3]; 2]> {
+ let root = self.surface_bvh.nodes.get(self.surface_bvh.root?)?;
+ Some([root.min, root.max])
+ }
+
/// Returns the validated source mesh retained for renderer integration.
///
/// Runtime collision queries use their own compact triangle representation;
@@ -1215,6 +1222,15 @@ mod tests {
}
#[test]
+ fn surface_bounds_cover_query_geometry_and_ignore_unused_positions() {
+ let mut mesh = synthetic_land_mesh();
+ mesh.positions.push([1000.0, -1000.0, 1000.0]);
+ let world = TerrainWorld::from_land_msh(&mesh).expect("world");
+ assert_eq!(world.surface_bounds(), Some([[0.0; 3], [1.0, 1.0, 2.0]]));
+ assert_eq!(TerrainWorld::default().surface_bounds(), None);
+ }
+
+ #[test]
fn synthetic_surface_height_and_raycast_work() {
let world = TerrainWorld::from_land_msh(&synthetic_land_mesh()).expect("world");
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index eac2321..f587743 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -10,6 +10,49 @@ authoring API без динамического подтверждения.
Не доказаны символами точные имена renderer vtable slots, полный набор CShade
state transitions и окончательный порядок части transparent/FX/shadow subpasses.
+После ограничения цели рельефом интерактивная камера проверяет движение
+против каждого отрисовываемого корневого строения класса `FORT` и его
+отрисовываемых компонентов. Для этого пути используются текущая Node38-поза
+компонента и его преобразование присоединения. При попадании камера сохраняет
+прежнюю позицию, но оставляет новый поворот. Так приложение связывает мировой
+MSH-запрос со свободной preview-камерой; это не подтверждает алгоритм
+стратегической или следящей камеры оригинала.
+
+Локальный MSH-запрос `query_slot_segment` сверён с оригиналом на фабричной
+стене и дверном проёме. Runtime-адаптер `query_building_segment_world` теперь
+повторяет helper batch bit `0x0008` через связанный узел, указанный raw-словом
+`Batch20 +0x06`. При повторе он обходит Node38-поддерево: сначала допустимых
+потомков, затем сам узел. Флаги ветвления сверены с оригиналом. Синтетические
+тесты покрывают связи, отсутствие геометрии у промежуточного узла, маски,
+равные попадания и предел в 200 пересечений.
+
+Игнорируемый тест приложения загружает настоящий PLANT 4 из AutoDemo и его
+исходную CTLD-привязку Node3 (`frame_a = 0`, `frame_b = 1`, blend `0`). В
+контроллере корневого PLANT нет записей tag-21 для открытия двери. В исходной
+закрытой позе камера блокируется о дверь через обычный путь обновления
+приложения. Для второго запроса тест вручную задаёт время `1` только Node3,
+обновляет иерархию позы компонента и преобразование присоединения, затем
+проверяет, что камера проходит через проём. Это подтверждает только подачу
+выбранных поз в коллизию приложения: тест не запускает событие открытия, не
+доказывает видимое открытие двери и не сравнивает полный игровой цикл с
+оригиналом.
+
+Открыты выбор первого корня и правила состава узлов/slot-ов оригинального
+мирового запроса, сравнение полного обхода с оригиналом, а также связь с
+игровыми позами и событиями. Текущий путь приложения использует все
+отрисовываемые компоненты строений `FORT`, но их выбор ещё не сопоставлен с
+поведением оригинала для первого корня и всего мира. Совпадение со
+стратегической и следящей камерами не подтверждено
+([MSH](../reference/msh.md)).
+
+Кривые alpha и метрика расстояния порталов реализованы для MSH без type 16
+([описание кадра](../reference/render-frame.md#прозрачность-порталов-msh)). Native CPU clipping может выбрать другую первую вершину;
+это приближение явно считается как `portal_fade_packet_selection_approx`.
+
+Данные type 16 ещё не декодированы. Внутренний ракурс «Конструктора» после
+настоящего входа игрока подтверждает исчезновение чёрной поверхности в проходе;
+для всей приёмки R2 ещё нужны пары обеих миссий на трёх дистанциях.
+
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
log на минимальных сценах с одним типом материала.
@@ -74,22 +117,124 @@ Iron3D и указатели `GetSuperAI` из `ai.dll` (таблица RVA `0x5
tie-векторы проверяют, как сам `FISTP` реагирует на каждый RC, но не измеряют
control word процесса.
-Отдельно проверены загрузка лицензированного AutoDemo в Rust и preview
-лицензированной кампании. AutoDemo даёт raw TMA anchor-ы
-`[(500.2857, 751.8217), (728.0, 449.0)]` и записи `[(500, 752, 0),
-(728, 449, 1)]`; preview кампании сохраняет package и статус `Unsupported`,
-не применяя частичные записи. Это подтверждает загрузчик и узкий Init resolver
-Rust.
+Лицензированный AutoDemo по-прежнему загружается с raw TMA anchor-ами
+`[(500.2857, 751.8217), (728.0, 449.0)]` и записями `[(500, 752, 0),
+(728, 449, 1)]`. Rust теперь также читает optional `.fml` sibling для каждого
+script base и поддерживает узкий набор атомарных формул в первом `Init`.
+Отсутствующий `.fml` не мешает пакету с одними `Handler(19)`; другие ошибки
+чтения распространяются как VFS errors.
Нативный конструктор показывает предупреждение при координате выше `10000`, но
продолжает работу. Rust моделирует числовую запись, а не это UI-предупреждение;
на первом `FISTP qword` он явно завершает загрузку ошибкой для NaN, infinity и
значений вне signed-qword диапазона. В первом событии `Init` Rust поддерживает
-только action-форму `Handler(19)` (`control word` и `result reference` равны
-`0xffffffff`). Неизвестная инструкция даёт `Unsupported` в preview, не применяя
-накопленные частичные записи; при полной загрузке это ошибка. Это ограничение
-Rust исполнителя, не правило оригинального dispatcher-а. Ошибочные ссылки и
-типы остаются ошибками в обоих режимах.
+два доказанных действия: `Handler(19)` и formula assignment selector `-1` при
+source `-1`, нулевой глубине условий и корректных индексах. У `Handler(19)`
+control находится в `header_words[3]`, а `header_words[1]` — result-save
+reference; оба равны `0xffffffff` в поддержанной форме. В дисковом заголовке
+formula assignment `header_words[1]` — индекс целевой varset ячейки,
+`header_words[2]` — source, а индекс formula row занимает `header_words[6]`;
+индексы цели и формулы в native record находятся по смещениям `+0x08` и `+0x1c`
+соответственно. Маппинг подтверждён
+`ai.dll!FUN_10011b20` (RVA `0x11b20`) и исполнителем `FUN_10012020` (RVA
+`0x12020`), SHA-256 `c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`.
+`Handler(19)` (`FUN_1000aa38`, RVA `0xaa38`) использует references `0..2`;
+`header_words[2]` его result-save guard-ом не проверяется.
+
+Formula table загружается из `<bundle-base>.fml`; native `FUN_100123b0`
+(RVA `0x123b0`) проверяет ordinal, compiler `FUN_100152a0` (RVA `0x152a0`)
+строит числовые, переменные и операторные узлы, а evaluator `FUN_10015b30`
+(RVA `0x15b30`) в проверенных путях `+` и `-` считывает значения переменных и
+сохраняет результаты как `f32` при x87 control word `0x007f`. В GOG-сборке
+`ai.dll` с SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` поиск
+переменной через varset vtable slot `+0x10` ведёт на RVA `0x16ad0`: сравнение
+байтов точное, регистр учитывается, при повторах берётся первое объявление.
+Getter `FUN_10013190` (RVA `0x13190`) читает type `3` как `f32`, type `5` как
+беззнаковый `DWORD` и возвращает число через x87. Затем evaluator сохраняет
+его как `f32` до операции. Оператор `+`
+считает right+left, `-` — left-right; результат также сохраняется как `f32`.
+Target type `3` сохраняет эти bits; type `5` проходит через `FUN_10012c00`
+(RVA `0x12c00`) и `__ftol` (RVA `0x1df70`) к raw DWORD с усечением к нулю.
+Изолированный x86 oracle на оригинальном `ai.dll` и настоящих ресурсах
+подтвердил таблицы `tut1_pl2`/`scr_pl_1`/`scream` с
+26/5/149 строками и 15 присваиваний: кампанийные `df0..df3` и `Single.01`
+`dT8/dT9` получают `0`; `scream` задаёт `fDifficulty = 0.8f` и остальные
+проверенные DWORD `0, 0, 1, 10, 0, 100, 400, 2`.
+
+Граница Rust остаётся узкой: один числовой токен либо ASCII-имя переменной,
+или два таких атома с одним бинарным `+`/`-`. Имя проверяется раньше числового
+преобразования, сравнивается с учётом регистра и выбирает первое совпадение;
+публичный `FormulaTable::evaluate_numeric_literal` принимает только числовые
+литералы. Временная копия значений начинается со значений по умолчанию и
+обновляется после каждого успешного действия `Init`, поэтому следующая формула
+видит предыдущие записи. Нет
+поддержки неизвестных операторов, унарных форм, третьего атома, отсутствующих
+или не-ASCII имён и source-copy ветви `source != 0xffffffff`; native source-copy
+переносит всю запись varset. Preview сохраняет package, но не применяет частичные
+writes; полный load отклоняет новый мир и сохраняет прежние world/RNG. Это
+политика ограниченного Rust resolver-а, а не правило оригинального dispatcher-а.
+
+Ресурсные строки с переменными найдены в поздних событиях: `c1m3e.fml` row 19
+(`0.8 + fT`) вызывается из `Fort_Captured`, row 22
+(`dBuildingProtectHits - dT2`) — из `PBM_BUILDING_PROTECT_Start`, а `c1m4e.fml`
+row 0 (`fTemp + 0.00001`) — из `PBM_BUILDING_INF_CAPTURE_Start`. Декодирование
+этих `.scr` не показывает эти формулы в первом `Init`. Игнорируемая проверка
+читает настоящие `.fml` и `varset.var`, но вставляет строки в синтетический
+`Init`; она проверяет resolver, а не оригинальное исполнение. Одноразовый x86
+oracle на исходной GOG `ai.dll` с SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` проверил
+13 отдельных присваиваний: 9 контролируемых выражений и 4 вызова с настоящими
+строками `.fml` (`c1m3e` rows 19/22, `c1m4e` row 0 дважды подряд). В каждом векторе
+native harness записывал вычисленный `f32`, типизированное raw-слово и getter;
+scratch-проход через публичный Rust parser/resolver совпал со всеми 13
+векторами по `f32`-результату и raw-записи. Для native вызовов control word
+временно принудительно устанавливался в `0x007f`: 87 наблюдений сохранили
+`0x027f` на входе и после восстановления,
+а canary-проверки прошли. Менялись только varset-копии в памяти. Это
+подтверждает компиляцию формулы, локальное вычисление и typed writes для этих
+входов, но не запуск исходного поиска и обхода события `Init`, исполнение поздних
+событий или поведение живой миссии.
+
+Лицензированный test загружает одну первую кампанийную миссию
+`CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma` после literal writes; это не проход
+всей цепочки кампании. Обычная `Single.01` доходит до следующего blocker-а:
+`Handler(43)` в clan 1, `Init` instruction 10. Rust не перескакивает его;
+preview оставляет varset этого клана на defaults, а полный load завершает ошибкой
+с rollback. В `fparkan-assets` теперь есть bounded TRF reader, pure ore/power
+core и `ResearchCandidateSet::refresh_selection`: последний получает уже
+упорядоченные entries и отдельный TRF-контекст, затем читает Unit DAT через VFS.
+Он не строит AI-каталог и не исполняет script instruction. Runtime всё ещё не
+создаёт владельца живого контекста, не передаёт реальные AI candidates и не
+выбирает результат Handler(43). Это не общий dispatcher и не загрузка игры.
+
+Начальный массив слота AI `+0x38` теперь восстановлен отдельно от этого
+исследовательского пути. В `iron3d.dll` SHA-256
+`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`
+`FUN_100392e0` (RVA `0x392e0`) вызывает слот для каждого значения счётчика
+`second_resource.tag`. В `ai.dll` SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` метод
+RVA `0x2010` передаёт `AI+0x83c` в `FUN_10002980` (RVA `0x2980`), где
+`FUN_100048f0` (RVA `0x48f0`) добавляет переданный DWORD. Вызывающая сторона
+повторно передаёт `0xffffffff`; эта функция не записывает значение обратно.
+Поэтому подтверждён только начальный массив sentinel-слов. Неизвестно, какие
+поздние обработчики читают эти ячейки и заменяет ли кто-либо их ID. В начальном
+массиве нет назначенных ID объектов; дальнейший смысл этих слов пока не
+установлен. Сам массив не является списком
+`BehaviorDistributorState.registered_objects` и не определяет владельца
+контекста исследований.
+
+Rust хранит этот снимок как `MissionAiStartupState` в состоянии загруженной
+миссии. Полная загрузка проходит по всем TMA-кланам, включая кланы без пакета
+сценария и без пути таблицы исследований; при предпросмотре список состояний AI
+остаётся пустым. Знаковое поле `second_resource.tag` читается по сырым битам как
+`u32`. Бюджет в 1 000 000 DWORD на миссию и выделение памяти с обработкой ошибки
+защищают только память Rust; это ограничение не приписывается оригинальному
+движку. Искусственные Rust-тесты проверяют обе формы TMA-клана, индекс клана,
+независимые массивы, пустой путь сценария, предпросмотр, чтение тега как
+беззнакового числа, лимит и откат при ошибке. Проверка на оригинальных ресурсах
+для `Single.01` подтверждает состояния обоих кланов (`9/9`) до выполнения
+`Init`; это не полный запуск миссии и не исполнение исходных DLL.
Для GOG-сборки также найден статический host call path в `Init`. В
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
@@ -169,6 +314,26 @@ Behavior (`Behavior.dll`, RVA `0x3600`) вызывает `World3D!stdGetGameMode
с реальными интерфейсами ArealMap, нужными `FUN_10019e80`; полный Behavior tick
flow и общий dispatcher событий также остаются открытыми.
+`loaded_mission_behavior_control_groupings` публикует только начальную
+группировку подготовленной Control-таблицы. Ветка с меткой `2` исходного
+`Behavior.dll!FUN_1001be10` (RVA `0x1be10`) дополнительно вызывает
+`FUN_1001b4b0` и обращается к свойствам через QI `0x204`, включая запросы с
+номерами `0x700`, `6` и `0x400`. Результат `0x700` попадает в строку кандидата
+по смещению `+0x3c`, результат `6` — в `+0x40`, а `0x400` может обнулить второе
+поле. Их доменный смысл, множитель `0x700`, агрегаты Behavior QI `0x204` и
+прочие числовые поля кандидата не восстановлены. Rust не подставляет множитель
+`0x700`, нулевые агрегаты, выбранный индекс или эффект. Список для метки `30` не
+встречается в ресурсах AutoDemo и проверяется только на искусственном наборе.
+
+Игнорируемая проверка AutoDemo сравнивает порядковые номера записей начальной
+группировки, отдельно выведенные по снимку текущих меток дескрипторов, с
+состоянием Rust после полной загрузки. Это подтверждает согласованность разбора
+подготовленной таблицы и правила группировки на этих ресурсах, но не является
+захватом состояния Behavior оригинала и не устанавливает, когда список
+прикрепляется или обновляется в живом объекте. Полный штатный путь загрузки и
+запуска, агрегаты Behavior QI `0x204`, обновление после изменений Control,
+`Handler(43)` и полный игровой цикл остаются отдельными границами.
+
Начальные свойства `CurrentOre`/`MaximumOre` восстановлены отдельно от
регулярного обмена. В проверенных `Behavior.dll` (SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) RVA
@@ -312,7 +477,9 @@ Global Control getters `1/2` и setter `1` проверены в native x86 orac
этого класса native oracle не запускал. Rust API обращается к строкам проекции
конкретного живого `ObjectHandle`; это запрос к уже загруженной таблице, а не
исполнение Control или Behavior tick. Setter `17`, остальные factory tags и
-внешние эффекты их методов не включены. Для tag `9` selector `0` использует
+внешние эффекты их методов не включены. Структурное чтение записи CTLD с
+меткой 17 ещё не восстанавливает её обработчики и игровое поведение.
+Для tag `9` selector `0` использует
отдельный scale `+0x11c`; остальные поддержанные классы используют коэффициент
напрямую. Пустой набор и нулевой знаменатель остаются native no-result, а
неподдержанный factory tag и расчётный диапазон произведения/отношения за
@@ -356,6 +523,41 @@ MSH-часть проверки подтверждает начальный сн
остаётся статическим выводом по RVA `AniMesh.dll+0xa460`, а последующие
изменения в полном Control/animation tick-е не проверялись.
+Устройство фабрики `2` через selector `6` читает `+0x174` только при текущем descriptor tag `2`,
+затем проверяет, что устройство создано фабрикой `2` и поле доступно. Значение
+должно быть конечным нормальным числом или нулём любого знака. Строки в
+`[27..35)` и `[35..43)` читаются до первого NUL: хвост игнорируется,
+ASCII-регистр не важен. После изменения пустая строка оставляет старые биты;
+пустая пара конструктора уже совпадает и не вызывает пересчёт. Проверка не
+использует список имён: нужен один корневой mesh `BULL` из `ObjectsRegistry`,
+без дочерних компонентов и записей timeline; все типизированные записи должны
+иметь tag `17`, а эффекты и таблица NDP — разбираться полностью. Число строк
+NDP произвольно, включая пустую таблицу.
+
+Сканер возвращает коды выбранной начальной группы в исходном порядке. Допустимы
+`1/4/12`. `Some([])` означает, что по полному разбору данных команд нет: группа
+отсутствует, пуста или сырое беззнаковое значение индекса не меньше числа
+групп. `None` означает неподдержанный либо неполный разбор; повреждённые данные
+дают ошибку. Для выбранного opcode `4` нужны FXID-типы `2/3/4/5/7/8/9` и
+допустимые CPT-смещения внутри компонента; типы `1/6/10` остаются недоступными.
+Непредставленный ключ или число не превращаются в ноль. Если распознанный
+ресурс повреждён, новая миссия завершается ошибкой до публикации, сохраняя
+прежние мир и RNG.
+
+Загрузка лицензированной AutoDemo и Rust-запрос selector `6` охватили 12
+устройств и семь пар с битовыми значениями:
+`bb_m_01=43f98000`, `bm_m_01=44228000`, `bl_l_01=436a0000`,
+`br_b_01=44c80000`, `bf_b_01=443b8000`, `bl_m_01=443b4000`,
+`bm_l_01=43340000`. Все семь значений совпали с замороженными арифметическими
+векторами. Пятнадцать изолированных вызовов selector `35` оригинальной DLL
+(включая семь однострочных BULL-таблиц) проверили арифметику при x87 control
+word `0x007f`, но не создавали полный native World. Таблица Control-устройств
+Rust по-прежнему строится при полной загрузке
+Unit DAT. CPU-проекция не воспроизводит временное создание объектов оригинала и
+его побочные эффекты FX, звука и RNG, размещение в World или следующий Control
+tick. Полное размещение объектов, игровой tick, Handler 43 и другие
+неподдержанные форматы остаются открытыми.
+
Для provider-строки Rust хранит amount `+0x08`, накопитель `+0x10`, обработанное
количество `+0x0c`, долю `+0x14`, стадию `+0x18` и flags `+0x28` раздельно.
Инициализатор `Control.dll+0xf940` кладёт результат selector `3` в текущее и
@@ -419,12 +621,48 @@ callbacks. Licensed двухстадийный узел шахты подтвеÑ
новый API при доле `0.5`, без FX и window path. Гибель root/critical узла и
неизвестные flags/aggregate также остаются явными ошибками.
-Rust query обращается к загруженному объекту и поддерживает `0xe00` только для
-текущего descriptor tag `26` с factory tag `26`; другие активные typed tags и
-factory paths остаются явными границами. Запрос читает текущее type-1 значение.
-Licensed `min_l_n1.dat` подтверждает изменение `0xe00` и Behavior `0x1002` с
-`1.0` до `0.5` после явного уменьшения количества вдвое; чтение получает
-текущее значение provider, который также меняет явный бюджетный шаг.
+Rust query обращается к загруженному объекту и поддерживает `0xe00` для пар
+текущего descriptor tag и factory tag `(5,5)`, `(5,26)`, `(26,5)` и `(26,26)`;
+прочие специализированные tag/factory пути остаются явными границами. Запрос
+читает текущее type-1 значение provider. Licensed `min_l_n1.dat` подтверждает
+изменение `0xe00` и Behavior `0x1002` с `1.0` до `0.5` после явного уменьшения
+количества вдвое; чтение получает текущее значение provider, который также
+меняет явный бюджетный шаг.
+
+Indexed selector `0x400` в том же Control IID `0x204` возвращает текущее type-1
+значение строки provider, выбранной через key устройства, при любом descriptor
+и factory tag. Rust принимает конечное normal-or-zero значение, включая оба
+знака нуля, и сохраняет его биты; недоступная таблица, отсутствующий key и
+значения вне этой численной области дают типизированную ошибку. В оригинальном
+`Control.dll` с SHA-256
+`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` ветвь
+`FUN_1002bb40` (RVA `0x2bc3c`) читает provider pointer `device+0x44` и key
+`device+0x04`, вызывает provider slot `+0x0c` в режиме `1` и не проверяет
+descriptor/factory tag. Rust-проекция читает сохранённый снимок; она не вычисляет
+value из amount, maximum, coefficients или множителя `M`. Native selector-cell
+alias/fallback за пределами валидной записи и поведение native при невалидном
+key остаются неисследованными; Rust не подставляет для них `0.0` или `None`.
+Это покрывает перечисленные query-пути. Полный интерфейс IID `0x204`, его
+взаимодействие со всеми Behavior-вызовами и полный игровой tick остаются за
+границей подтверждённого поведения.
+
+Объём свидетельств для `0x400` ограничен и состоит из трёх частей. Native
+oracle на настоящем `Control.dll` проверил шесть выходов по raw bits для
+ограждённого синтетического owner/provider table; он вызвал indexed query и
+реальный provider getter, но не создавал оригинальные factory-объекты. Матрица
+выходов находится в [техническом разборе](script-vm.md#индексный-control-getter-сырой-селектор-0x400).
+Независимый разбор AutoDemo обнаружил восемь таблиц и 100 устройств с
+доступными ключами, но все выбранные provider values равны `1.0`; такой набор
+подтверждает загрузку и область ключей, однако не различает, какую именно
+строку выбирает алгоритм. Игнорируемый Rust-тест опрашивает эти 100 устройств и
+проверяет те же биты, не выдавая равенство ресурсных данных за доказательство
+индексирования.
+
+Вместе native-векторы и resource acceptance не подтверждают состояние
+оригинальных factory instances, полный контракт IID `0x204`, полный
+Behavior-call flow или живую миссию/gameplay. Поведение при ошибочных native
+указателях и ключах, а также selector-cell alias/fallback вне валидной строки
+по-прежнему не описано; Rust сохраняет typed errors на этой границе.
Native oracle проверил изолированные пути распределения, обновления класса `4`,
стадийную арифметику `0x11220`, обход дочерних provider-строк `0x11130` на
interleaved parents `[-1, 0, 0, 1, 3]` с callback-порядком `1, 3, 4, 2` и
@@ -562,16 +800,19 @@ resource-device значения во время игры.
`0x5780/0x57c0` участвуют в векторе; Rust воспроизводит его raw output bits и
отдельные cache, order, clamp и signed-zero случаи.
-Для обычной начальной загрузки scale-input тоже закрыт. AniMesh constructor
-оставляет `+0x158/+0x15c/+0x160` равными `1.0`; перед Control event `4` World3D
-проходит Agent event `0`, AniMesh RVA `0x1a80` вызывает базовый return `0x17df0`
-и передаёт event `4/null` модели, FX, CollManager и CollObject. Их проверенные
-ветви `0x75b0/0x6fc0`, Effect RVA `0x3d30`, Control RVA `0x1ce00` и `0x1fe00`
-не вызывают AniMesh setter `0x14770` и не меняют outer scale. Поэтому FAC0 в
-стандартном `LoadAgent` получает `[1, 1, 1]`. Другой маршрут
-`AddNewObjectToGame`/mirror к setter и последующие placement/tick остаются
-отдельными границами. В synthetic x86-векторе по-прежнему явно заданы `[2,3,5]`;
-он не является источником scale для полного load.
+Для обычной начальной загрузки входной масштаб единичный. Конструктор модели
+AniMesh RVA `0x68a0` задаёт `Model+0x158/+0x15c/+0x160 = 1.0`;
+`Agent+0x158` хранит указатель на отдельный Control. Перед Control event `4`
+World3D проходит Agent event `0`: AniMesh RVA `0x1a80` вызывает базовый return
+`0x17df0` и передаёт event `4/null` модели, FX, CollManager и CollObject.
+Проверенные ветви модели `0x75b0/0x6fc0`, Effect `0x3d30` и CollManager
+`0x1ce00` не меняют масштаб. CollObject получает событие через основной
+интерфейс `0x3c2fc`, slot `+0x08 -> 0x1f530`: он кэширует host-интерфейсы,
+а необязательный вызов CollManager `0x1c330` сохраняет только указатель
+`CollObject+0x20` в `CollManager+0x3c`. Поэтому FAC0 получает `[1, 1, 1]`.
+Отдельные пути `AddNewObjectToGame`/mirror и последующие placement/tick остаются
+за этой границей. В synthetic x86-векторе scales `[2,3,5]` заданы отдельно.
+Подробная привязка интерфейсов приведена в [разборе Script VM](script-vm.md).
Смысл `Slot68 +0x30/+0x34`, tag-27 cache DWORD, owner accumulators `+0x530`,
`+0x534`, `+0x538` и результата mutable word `26` не имеет доказанных единиц
@@ -644,26 +885,142 @@ Native interoperability подтверждается только успешны
Граница shell подтверждена exports `createShell/getIShell`, `IGUIServer`,
верхнеуровневым UI-pass и файлами `ui/*.cfg`, `DATA/TextRes.cfg`,
-`gamefont.rlb` и `sprites.lib`. RsLi framing библиотек закрыт, но widget tree,
-layout rules, glyph metrics, sprite command semantics, focus/navigation и HUD
-state machine пока не восстановлены до field-level спецификации.
-
-Закрывающий эксперимент: трассировать загрузку `shell_ctrls.cfg`,
-`menu_resources.cfg`, `cursor.cfg`, `game_resources.cfg` и `hq.cfg`, сопоставить
-GUI object factories и снять command/event captures для меню, HUD, briefing и
-диалогов.
+`gamefont.rlb` и `sprites.lib`. Для полной GOG-сборки подтверждены раздельные
+форматы: `gamefont.rlb` читается общим RsLi reader, но его native consumer этим
+не установлен; для меню подтверждена цепочка `menu_resources.cfg` → NRes
+`font.lib` → native lookup `MENU_FONT` с получением реального font-объекта.
+Reader `fparkan-font` разбирает 20-байтовый Tfnt prefix, 256 записей фиксированного
+шага и передаёт вложенный Texm общему reader-у. Native consumer подтверждает
+два целочисленных параметра, per-byte spacing и signed advance; смысл трёх glyph DWORD
+и поля заголовка по `0x0c` пока не установлен. Это не реализует выбор шрифта, текстовый
+layout или glyph drawing. `menu_resources.cfg` и шесть вариантов
+`MENU_FONT`/`GAME_FONT` сопоставлены с NRes-записями в
+[разборе shell и шрифтов](ui-shell.md). Также остаются открытыми выбор dimension
+на фактическом дисплее `1280x1024`, widget tree, layout rules, sprite command
+semantics, focus/navigation и HUD state machine.
+
+Закрывающий эксперимент: сопоставить оставшиеся детали `shell_ctrls.cfg`,
+`cursor.cfg`, `game_resources.cfg` и `hq.cfg` с GUI object factories и снять
+command/event captures для меню, HUD, briefing и диалогов.
## Research, economy and properties
-Экспорты `LoadResearch`, `CalcFullResearchCost`, TRF/preload resources и TMA
-properties доказывают отдельный слой исследований, стоимости, добычи и
-производственных параметров. Формулы стоимости, dependency graph технологий,
-inventory/economy transitions и точная типизация всех 16-byte property values
-не закрыты.
-
-Закрывающий эксперимент: сопоставить research functions с ресурсами и UI,
-снять изменения state на контролируемых покупках/исследованиях и построить
-typed schema свойств по consumers, а не по одному имени.
+Для GOG `ArealMap.dll` SHA-256
+`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`
+`CalcFullResearchCost` по RVA `0x16280` имеет подтверждённый ABI
+stdcall `[context, candidate, out1, out2]`, `RET 0x10`, и восстановленный
+алгоритм для одного candidate tree: zero-kind subtree pruning, direct preorder,
+общая дедупликация, ordered DFS по `TRF2`/`TRF3`, raw gates из `TRF1` и
+последовательное `f32` сложение. Принятый набор из 23 synthetic native cases
+включал Calc и два AI predicate; для контролируемых сравнений x87 word был
+установлен в `0x007f`. У набора synthetic context, поэтому он не измеряет
+реальную миссионную стоимость.
+
+Отдельный прямой вызов chooser-а `ai.dll` SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`
+`FUN_10010f90` (RVA `0x10f90`) прошёл 3 guard-case и frozen-матрицу из 15
+проверок при заданном порядке кандидатов. Тот же код использует конструктор RVA
+`0x10480`, parser первого Unit DAT дерева RVA `0x102c0` и предикаты RVA
+`0x115f0`/`0x11530`. Wrapper Handler(43) по RVA `0xd54a` и catalog loader
+`0x104e0` не вызывались. Эта матрица не подтверждает полный Handler(43),
+миссионный источник списка или штатный порядок AI-каталога.
+
+`fparkan-assets` декодирует используемые NRes chunks, сохраняя сырые TRF0 words
+и непрозрачные данные. Native cost outputs сопоставлены только с двумя raw
+words: out1 складывает TRF0 word 1 и подписан как ore, out2 складывает word 0
+и подписан как power. Смыслы остальных полей полного 32-byte callback record
+не установлены. Non-ASCII case folding research keys также остаётся открытым;
+Rust явно возвращает unsupported для таких сравнений.
+
+Rust `ResearchCandidateSet` получает ordered entries, а TRF-context передаётся
+отдельно. Ошибка чтения VFS, неверный Unit DAT magic или неполное первое
+дерево пропускают entry; missing key даёт false predicate. Cost вызывается и
+для пустой таблицы: он возвращает `EmptyResearchTable`, после чего этот entry
+пропускается с сохранением второго status.
+Другие ошибки таблицы и
+`UnsupportedNonAsciiLookup` остаются явными Result-ошибками. Уже записанные
+statuses и выбранный индекс на ошибке не откатываются; это политика Rust API,
+не доказательство полного native error ABI.
+
+Два Rust-предиката повторно обходят один декодированный tree с той же
+immutable TRF-таблицей. Общий helper может остановить второй проход при первом
+ложном флаге; native RVA `0x11530` продолжает обход и накапливает
+неиспользуемые float sums. Первый проход уже проверил ключи всех enabled
+компонентов, поэтому подтверждается только эквивалентность state/selection
+для такого входа, не идентичность callback trace или вычисления этих sums.
+
+Статический перенос владельца исследовательского контекста подтверждён по трём
+GOG-модулям. В `iron3d.dll` (SHA-256
+`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`)
+`FUN_1005ff40` (RVA `0x5ff40`) передаёт индекс клана в `FUN_100392e0` (RVA
+`0x392e0`), где он сохраняется в `row+0x1c` и копируется в `descriptor[0]`.
+Thunk RVA `0xcd0a0` ведёт к `World3D!GetNetPlayerNum` (RVA `0x9110`), а RVA
+`0xcd08e` — к `World3D!AddNewObjectToGame` (RVA `0x8310`); SHA-256
+`World3D.dll`: `17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`.
+Вызов getter-а без аргументов завершается обычным `RET`; его результат становится
+arg1 следующего вызова. `AddNewObjectToGame` принимает девять DWORD, включая
+указатель на descriptor в arg9, и очищает стек через `RET 0x24`. RVA `0x83e9`
+читает arg9; ветвь class `0xb`/QI `0x502` на RVA `0x853e` загружает
+`descriptor[0]` и передаёт его в слот `+0x5c` на RVA `0x8544`;
+`MisLoad.dll` (SHA-256
+`c607762bb67db14ca530927c8e1347339b62b0a163ac90c708ee83142292e2bf`)
+`FUN_10002a30` (RVA `0x2a30`, `RET 8`) записывает его в `inner+8`. TMA tag
+`row+0x20` ограничивает число вызовов AI slot `+0x38`, но не задаёт владельца.
+
+Создание AI не фильтруется по наличию сценарного пути. В `iron3d.dll` SHA-256
+`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`
+`FUN_1005ff40` (RVA `0x5ff40`) проходит записи кланов и вызывает для каждой
+`FUN_100392e0` (RVA `0x392e0`); в `ai.dll` SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`
+`CreateSuperAI` (RVA `0xf710`) выделяет AI и вызывает конструктор по RVA
+`0x1000` без проверки пути сценария. Создание исследовательского контекста
+следует за успешным созданием AI и требует включённого флага создания
+исследований и непустого `second_resource.path` до первого NUL. Переданный в
+`descriptor[0]` индекс клана, а не `second_resource.tag`, задаёт владельца;
+тег остаётся счётчиком начальных вызовов AI slot `+0x38`.
+
+В Rust эта граница представлена только загрузкой таблиц. Полный
+`load_mission` требует явный `MissionStartupInput { queue_time_ms,
+research_creation_gate }`; этот флаг не выводится из `MissionMode` или имени
+пути. Если флаг равен `true`, загрузчик проходит по существующим
+`MissionAiStartupState` всех TMA-кланов, в том числе без сценарного пакета,
+читает их `second_resource.path` до первого NUL и отклоняет не-ASCII путь.
+Пустая строка пропускается; выключенный флаг и статический preview не читают
+TRF. На каждого
+владельца сохраняется отдельный `ResearchTrfDocument` с нормализованным путём,
+даже когда разные кланы указывают на один файл. Обычные тесты на искусственных
+данных проверяют владение у кланов со сценариями и без них, повторное
+декодирование, пропуски, ошибки и откат; игнорируемая проверка оригинальных
+ресурсов связывает реальные `Campaign.00/Mission.01` пары путей с индексами владельцев.
+Rust пока сохраняет только декодированные данные и связь с владельцем: он не
+создаёт полный исследовательский контекст, не строит AI-каталог и не исполняет
+`Handler(43)`.
+
+Нативный metadata loader нельзя считать простым копированием таблицы. В GOG
+`Behavior.dll` (SHA-256
+`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) selector
+`0x204` по RVA `0xa490` обновляет сохранённое float-значение `+0x678`. В GOG `Control.dll`
+(SHA-256 `bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`)
+selector `0x36` проходит цепочку RVA `0x31af0` → `0xe6c0` и записывает в общую
+ячейку памяти по RVA `0x434bc`; selector `0x91` выполняет одноразовую
+инициализацию, только если функция ещё не выполнила её. В Rust пока хранятся
+только декодированные TRF-данные и сведения о владельце: эти побочные записи,
+очередь и lifetime контекста не моделируются.
+
+Открыты runtime-выполнение Handler(43), создание и lifetime research context,
+включение factory-цепочки в полный штатный startup миссии, построение AI
+candidate list и штатный порядок кандидатов, live x87 policy,
+inventory/economy transitions и точная типизация всех 16-byte TMA property
+values. Strict ore comparison и выбор первого кандидата при равенстве известны
+только для supplied-order chooser. Ignored acceptance проверяет реальные Part 1
+AI DAT prefix и `scream.trf`, но byte-sorted порядок не заявляется как native
+catalog order и не сравнивает цену с вызовом chooser-а в mission context.
+
+Для проверки оставшихся границ нужно на живой миссии установить, проходит ли
+штатный `Init` по статически восстановленной передаче owner, зафиксировать
+создание и lifetime исследовательского контекста и сравнить порядок кандидатов
+и cost outputs оригинала с Rust. Покупки, inventory transitions и typed
+property schema требуют отдельных контролируемых исследований.
## Rare branches
diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md
index 3de442d..f7f8b97 100644
--- a/docs/appendices/script-vm.md
+++ b/docs/appendices/script-vm.md
@@ -16,21 +16,78 @@ handlers, второй — числом event records. Каждый event хра
`name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных
records. Вложенный record сохраняет семь `u32` header words (в disk order),
список `u32` references после шестого header word и trailing seventh word.
-Никакой из этих words ещё не получает semantic name. `.fml` — текстовый
+Семантика остальных слов остаётся открытой; поля control и result-save описаны
+в узком `Init` contract ниже. `.fml` — текстовый
symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaults.
`fparkan-script::parse_varset` уже читает подтверждённые numeric
`VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются
opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и
-`FUNCTION(...)` пока сохранены за границей этого numeric contract;
+general `FUNCTION(...)` evaluation остаются вне восстановленного контракта,
+но принятые строки сохраняются для ограниченного поднабора формул первого `Init`;
GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration:
31 `float` и 200 `DWORD` (от `f0` до `fY`);
loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при
`not found` откатывается к этому shared file. Runtime повторяет данный порядок
-транзакционно и публикует selected `MissionScriptVarSet` с путём/provenance, но
-ещё не исполняет declarations как VM state;
+транзакционно, создаёт typed varset cells для кланов и применяет поддержанные
+записи первого `Init`. Это начальное состояние сценария, а не исполнение общего
+VM state;
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
consumer-inferred.
+### Исследовательский профиль TRF и стоимость кандидата
+
+Для одной ветви `Handler(43)` уже восстановлен путь вычисления стоимости.
+Результат привязан к GOG `ArealMap.dll`, SHA-256
+`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`,
+`CalcFullResearchCost` по RVA `0x16280`. Реальный ABI — четыре аргумента
+stdcall на стеке: `[context, candidate, out1, out2]`; функция снимает с него
+`0x10` байт и не принимает `candidate` через `ECX`.
+
+Источник этих таблиц привязан к GOG `MisLoad.dll`, SHA-256
+`c607762bb67db14ca530927c8e1347339b62b0a163ac90c708ee83142292e2bf`.
+`FUN_10002fe0` (RVA `0x2fe0`) загружает required TRF chunks. Внутренний
+research context разрешает ключ через `FUN_10002a40` (RVA `0x2a40`), выдаёт
+32-byte row record через `FUN_10002aa0` (RVA `0x2aa0`) и возвращает ordered
+dependency array через `FUN_10002cf0` (RVA `0x2cf0`). Эти методы соответствуют
+виртуальным слотам context `+0x08`, `+0x0c` и `+0x24`; native Calc получает их
+через таблицу вызовов, а не напрямую из файлов.
+
+`ResearchTrfDocument` в `fparkan-assets` повторно использует NRes reader и
+сохраняет исходный архив. `TRF0.attr1` задаёт число строк по 40 байт; native
+загрузчик требует `TRF0.attr2 == 3`. `TRF1` задаёт один флаговый byte на строку:
+Rust воспроизводит native zero-fill при коротком payload и отклоняет более
+длинный. `TRFB.attr1` задаёт пары little-endian `(u16 name_offset, u16 row)`;
+имя берётся из NUL-terminated pool `TRF6`. Поиск идёт по исходному порядку и
+берёт первую ASCII-case-insensitive пару. Связь строки с парой использует
+только младшие 16 бит сырого слова `TRF0` по смещению `+0x20`.
+
+Две таблицы зависимостей имеют собственные размеры: `TRF2` считает группы,
+`TRF3` содержит их плоский упорядоченный поток индексов; `TRF4`/`TRF5` образуют
+отдельную пару. Только `TRF2`/`TRF3` участвуют в `CalcFullResearchCost`.
+Отсутствующий `TRF3` или `TRF5` означает пустой поток лишь при нулевой сумме
+счётчиков соответствующей таблицы. Required `TRF7`–`TRFA` остаются
+непрозрачными. Reader проверяет фактически читаемые срезы, строки, ссылки и
+размеры выделений для памяти Rust; это дополнительная безопасность, а не
+утверждение, что native loader отвергает те же плохие файлы.
+
+Из флагового byte `TRF1` native provider строит слова record `+0`, `+4` и `+8`
+из битов `(1, 0, 2)`. `CalcFullResearchCost` использует первые два как raw gate,
+не присваивая им общего игрового имени: `+0` определяет, добавлять ли строку,
+`+4` — раскрывать ли её зависимости. Нулевой `kind` компонента Unit DAT
+отсекает весь узел с потомками. Иначе дерево сканируется в preorder; найденные
+строки добавляются один раз, затем для каждой прямой строки выполняется
+упорядоченный DFS по `TRF2`/`TRF3`. Общая таблица посещений завершает циклы и
+удаляет повторы.
+
+По debug-строкам `out1` — ore и суммирует `TRF0` word 1, а `out2` — power и
+суммирует word 0. Native код хранит каждый шаг суммы как `f32`; один cost-вектор
+`1, 2^24, -2^24` даёт первый результат `0`, а в другом порядке
+`2^24, -2^24, 1` даёт `1`. Rust core сохраняет тот же порядок накопления. В
+принятом наборе из 23 синтетических вызовов оригинальных DLL были случаи
+`CalcFullResearchCost` и два AI-предиката; x87 control word был установлен в
+`0x007f` для контролируемых сравнений. Набор проверяет выбранные ветви на
+синтетическом контексте, но не использует реальный контекст миссии.
+
## Безопасная модель исполнения
Целевая VM должна разделять immutable package (bytecode, symbols, events,
@@ -282,11 +339,15 @@ TMA properties остаются four raw `u32` words плюс имя, пока c
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
`ClanID`, ore, speed и free-time properties.
-Research/economy работают в simulation: `LoadResearch` и
-`CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный
-layout prerequisites/modifiers/unlocks. Formula evaluator требует strict
-grammar/version, typed operands, deterministic numeric policy, bounded stack и
-явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
+Для одного кандидата и одной research-таблицы порядок prerequisite-closure,
+дедупликация и две выходные стоимости `CalcFullResearchCost` восстановлены и
+проверены отдельно, как описано в
+[исследовательском профиле TRF](#исследовательский-профиль-trf-и-стоимость-кандидата).
+Это не закрывает production-modifier/unlock data, экономические переходы или
+полное исполнение Handler(43). Общий evaluator формул всё ещё требует
+versioned grammar, typed operands, deterministic numeric policy, bounded stack
+и явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
+Описанный ниже literal-путь первого `Init` не закрывает общий evaluator.
### Первый фрагмент dispatcher-а: `Init` и Handler(19)
@@ -302,10 +363,21 @@ image base `0x10000000`. В ней `FUN_10011f40` (RVA `0x11f40`) ищет имÑ
`FUN_10011f10` передаёт выбранный индекс внутреннему циклу `FUN_10011fb0`
(RVA `0x11fb0`). Цикл начинает с первой вложенной инструкции, сбрасывает индекс
инструкции и глубину стека условий, затем идёт по записям в порядке файла.
-В исходном `default.scr` событие `Init` содержит одну инструкцию selector `19`,
-control word `0xffffffff` (действие) и result reference `0xffffffff` (результат
-не сохраняется). Это наблюдаемая форма пакета; поведение оригинала для остальных
-форм здесь не утверждается.
+Читатель `FUN_10011b20` (RVA `0x11b20`) переставляет семь disk header words в
+runtime record: `[0, 1, 2, 3, 4, 5, 6]` переходят в offsets
+`[+0, +8, +0x0c, +4, +0x10, +0x18, +0x1c]`; указатель references лежит в `+0x14`.
+Исполнитель `FUN_10012020` (RVA `0x12020`) берёт control из runtime `+0x04`, то
+есть из `header_words[3]`, а result-save reference — из runtime `+0x08`, то есть
+из `header_words[1]`. Disk word `header_words[2]` попадает в `+0x0c` и участвует
+в особой ветви selector `-1`, но не является result-save полем `Handler(19)`.
+
+В исходном `default.scr` событие `Init` содержит действие selector `19` с
+заголовком `[19, 0xffffffff, 0xffffffff, 0xffffffff, 6, 3, 0xffffffff]` и
+references `224, 225, 226`. Для этого действия `header_words[3] = 0xffffffff`
+(action control), а `header_words[1] = 0xffffffff` (результат не сохраняется).
+Одинаковое значение в `header_words[2]` — лишь содержимое этой записи, оно не
+проверяется guard-ом `Handler(19)`. Это наблюдаемая форма пакета; поведение
+оригинала для остальных форм здесь не утверждается.
В GOG `default.scr` события `Init` в двух AutoDemo bundles имеют по одной такой
инструкции. Её ссылки `224`, `225`, `226` ведут к объявлениям `DWORD`
@@ -350,10 +422,15 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
показывает исходное предупреждение `MessageBoxA`; оно находится за пределами
этого числового resolver-а.
-Ограниченный Rust-исполнитель `VarSet::resolve_handler19_init` ищет только
-первое событие `Init`, проходит его инструкции по порядку и возвращает
-упорядоченные записи. Runtime применяет их к отдельной копии varset выбранного
-клана лишь после успешного прохода всего события. Отсутствующий `Init` даёт
+Ограниченный Rust-исполнитель `VarSet::resolve_init` ищет первое событие
+`Init` и проходит его инструкции по порядку. Он возвращает записи для
+поддержанного `Handler(19)` и формул с одним числовым или переменным атомом либо
+с двумя такими атомами, соединёнными одним бинарным `+` или `-`; прочая форма
+останавливает preview на `Unsupported`. Runtime начинает вычисление с копии
+defaults и записывает в неё каждый успешно разрешённый шаг, поэтому поздняя
+формула видит прежние записи этого события. Упорядоченные записи попадают в
+состояние клана только после успешного прохода всего `Init`. Отсутствующий
+`Init` даёт
статус `Missing` и не создаёт записей инициализации; пустой первый `Init` даёт
`Executed` без записей. Если форма инструкции пока не поддержана, package
остаётся доступен в preview режиме со статусом `Unsupported`, а default-значения
@@ -363,6 +440,131 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
Это правила ограниченного Rust-исполнителя, а не обнаруженное поведение
оригинального dispatcher-а.
+#### Литералы и простые формулы `.fml` в `Init`
+
+В той же сборке `ai.dll` (`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`)
+загрузчик `FUN_10001000` открывает `.fml` рядом с базой каждого `.scr`.
+`FUN_10018f40` читает строки в таблицу `FUNCTION`; vtable slot `+0x18`
+компилирует сохранённые строки через `FUN_100152a0` (RVA `0x152a0`).
+Индексы формул — порядковые номера принятых строк, поэтому строки после
+используемого номера тоже остаются в таблице. Native reader обрабатывает
+независимые чанки до 255 байт, включая LF; CRLF нормализуется до LF, `Ctrl-Z`
+завершает ввод, а NUL скрывает остаток своего чанка для C-string поиска.
+Искатель учитывает регистр, ищет подстроку `FUNCTION` и независимо выбирает
+первую `(` и запятые в видимом чанке. Это permissive framing, а не строгий
+разбор сбалансированного вызова. Rust сохраняет принятые записи в этом порядке
+и разбирает выражение только при ссылке из `Init`; отсутствующая соседняя `.fml`
+не мешает одному лишь `Handler(19)`, но ошибка чтения, отличная от `NotFound`,
+остаётся ошибкой VFS.
+
+Native selector `-1` при source `-1` и нулевой глубине условий выбирает
+formula assignment. В дисковом заголовке `.scr` `header_words[1]` — индекс
+целевой varset-ячейки, а `header_words[6]` — индекс formula row; соответствующие
+поля native record находятся по смещениям `+0x08` и `+0x1c` соответственно.
+`FUN_100123b0` (RVA `0x123b0`) проверяет индекс строки. Компилятор
+`FUN_100152a0` (RVA `0x152a0`) создаёт
+числовые узлы, узлы переменных и узлы операторов; числовой prefix проходит через
+`FUN_10020af0` (RVA `0x20af0`) как `double`, после чего сохраняется в узле как
+`float`. Для переменной компилятор вызывает vtable slot `+0x10` таблицы varset;
+в проверенной GOG-сборке `ai.dll` этот вызов ведёт к поиску по RVA `0x16ad0`.
+Поиск сравнивает байты имени с учётом регистра и возвращает первое совпадение.
+`FUN_10015b30` (RVA `0x15b30`) читает числовой узел как `f32`, а переменную —
+через `FUN_10013190` (RVA `0x13190`): type `3` даёт `float`, type `5` —
+беззнаковый `DWORD`. Getter возвращает число через x87; evaluator сохраняет
+его как `f32` перед операцией. Оператор `+` складывает правый
+и левый операнды, а `-` вычисляет левый минус правый; после узла результат
+сохраняется как `f32`. `FUN_10013650` передаёт итог типизированной ячейке через
+`FUN_10012c00` (RVA `0x12c00`).
+
+Rust в первом `Init` принимает один числовой токен или имя ASCII-переменной,
+а также ровно два таких атома с одним бинарным `+` либо `-`. Например,
+`source + 2.0` и `target - source` читают значения из defaults или из предыдущих
+успешных записей события. Имя ищется раньше числового преобразования, поэтому
+объявление с именем `1e-2` перекрывает такую числовую запись; сравнение имён
+чувствительно к регистру, а повтор объявлений выбирает первое. `DWORD` читается
+как беззнаковое число и округляется до `f32` до операции. Публичный
+`FormulaTable::evaluate_numeric_literal` по-прежнему принимает только литералы.
+Ведущие unary `+`/`-`, `*`, другие операторы, третий атом, отсутствующие или
+не-ASCII имена и зарезервированные одиночные операторы остаются `Unsupported`;
+так же не поддержана ветвь копирования `source != 0xffffffff`.
+
+Проверенный native tokenizer отличается от обычного строгого decimal parser:
+`1e-2`, `1.`, `1e`, `1e+`, `1ee2` и `1e2.3` были приняты как один literal node
+и дали payload `0x3c23d70a`, `0x3f800000`, `0x3f800000`, `0x3f800000`,
+`0x3f800000` и `0x42c80000`. Для четырёх неполных exponent-форм значение
+получено из распознаваемого числового prefix; это именно проверенные строки,
+а не полное описание всех ошибок компилятора.
+
+Тип целевой ячейки сохраняется при записи: `float` (native tag `3`) принимает
+исходные `f32` bits; `DWORD` (tag `5`) проходит через `__ftol` (RVA `0x1df70`)
+с усечением к нулю и записывает младший DWORD. Поэтому literal `0.8` остаётся
+`0x3f4ccccd` в `float`, но записывается как `0` в `DWORD`; `100.0` даёт raw
+DWORD `100`. Неизвестные выражения не заменяются нулём, а записи первого `Init`
+публикуются только после успешного прохода.
+
+Native x86 oracle на GOG `ai.dll` SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` загрузил
+реальный shared `varset.var` (231 declaration) и реальные таблицы
+`tut1_pl2.fml` (26 строк), `scr_pl_1.fml` (5 строк) и `scream.fml` (149 строк).
+В первой кампании formula `0` записывает нули в DWORD `df0..df3` (indices
+188–191); в `Single.01` formula `0` записывает нули в `dT8` и `dT9` (indices
+186–187). Строки `scream` `0..8` задают следующие пары literal → raw cell value:
+
+| Formula | Target | Literal → raw cell value |
+| ---: | --- | --- |
+| 0 | `dMaxBuilder` (`DWORD`) | `0.4 → 0` |
+| 1 | `fDifficulty` (`float`) | `0.8 → 0x3f4ccccd` |
+| 2 | `dCurrentSender` (`DWORD`) | `0.5 → 0` |
+| 3 | `dMaxTower` (`DWORD`) | `1.0 → 1` |
+| 4 | `dMaxStorage` (`DWORD`) | `10 → 10` |
+| 5 | `dMaxTransport` (`DWORD`) | `0.2 → 0` |
+| 6 | `dMaxMine` (`DWORD`) | `100 → 100` |
+| 7 | `dMaxPlant` (`DWORD`) | `400 → 400` |
+| 8 | `d7` (`DWORD`) | `2 → 2` |
+
+Отдельный одноразовый x86 oracle на той же GOG `ai.dll` проверил ещё 13
+выражений: 9 строк контролируемой `controlled.fml`, строки 19 и 22 настоящей
+`c1m3e.fml` и два последовательных вызова строки 0 настоящей `c1m4e.fml`.
+Native harness записывал `f32`-результат evaluator-а, типизированную raw-запись
+и getter после записи. В scratch-проверке те же входы прошли через публичные
+`parse_varset`, `FormulaTable::parse` и `VarSet::resolve_init`; результат
+evaluator-а и raw-слово Rust совпали с native во всех 13 случаях. Среди
+проверенных границ: `2 - 3` записал в `DWORD` `0xffffffff`; чтение исходного
+`DWORD 0xffffffff` дало `f32 0x4f800000`, а последующее `+ 0` записало в
+`DWORD` ноль. Для настоящих строк `c1m3e` получены `fTemp = 0x40333333` и
+`dTemp3 = 0`; два последовательных вызова строки 0 `c1m4e` дали
+`fTemp = 0x4033335d`, затем `0x40333387`.
+
+Oracle принудительно устанавливал x87 control word `0x007f` на время каждого
+проверяемого вызова: все 87 наблюдений показали входное и восстановленное
+значение `0x027f`, активное и выходное — `0x007f`; canary-проверки прошли.
+Менялись только varset-копии в памяти процесса. Эта выборка подтверждает
+компиляцию, вычисление и типизированные записи ровно для перечисленных входов.
+Она не запускала исходный поиск и обход события `Init` и не исполняла поздние
+события или миссию; выборка не доказывает полную грамматику evaluator-а.
+
+Rust сохраняет всю таблицу строк, но вычисляет только формулу, на которую
+ссылается поддержанная запись. Лицензированный runtime-тест подтверждает
+полную загрузку кампании Mission.01 после этих присваиваний; у обычной
+`Single.01` clan 1 после формул остаётся `Handler(43)` в instruction 10, который
+не поддержан и не обходится. Preview оставляет значения этого клана без
+частичных записей, а полный load возвращает ошибку транзакционно. В
+`fparkan-assets` теперь отдельно доступны bounded TRF reader и pure cost core
+для одного Unit DAT, но script/runtime пока не создают нужный research context,
+не перечисляют AI candidates и не выполняют этот handler. Это не подтверждение
+запуска самой игры или полной VM.
+
+В других строках тех же ресурсов встречаются `0.8 + fT` (`c1m3e.fml`, row 19),
+`dBuildingProtectHits - dT2` (row 22) и `fTemp + 0.00001` (`c1m4e.fml`, row 0).
+Декодированные `c1m3e.scr` и `c1m4e.scr` ссылаются на них из более поздних
+событий `Fort_Captured`, `PBM_BUILDING_PROTECT_Start` и
+`PBM_BUILDING_INF_CAPTURE_Start`, а не из их `Init`. Игнорируемый Rust-тест
+подаёт эти сохранённые строки в изменённый синтетический `Init` и использует
+настоящий общий `varset.var`; это проверка resolver-а, а не утверждение о
+исполнении таких выражений в исходном `Init`. Отдельный native oracle и
+сравнение с Rust выше проверяют эти строки при явно заданных исходных ячейках,
+но не подтверждают их исполнение в оригинальном сценарии или в живой игре.
+
### Первый вызов Behavior и сценарного `Init`
В той же GOG-сборке `Behavior.dll` (SHA-256
@@ -610,10 +812,54 @@ tie-векторов подтверждает реакцию `FISTP`, но не
Нижнеуровневый `VarSet::resolve_handler19` принимает уже преобразованные первые
два слова и третье исходное слово, выдаёт три типизированные записи и отклоняет
-отсутствующие, выходящие за диапазон или не-`DWORD` цели. Метод
-`resolve_handler19_init` добавляет выбор события и остановку на неподдержанной
-форме инструкции. Всё остальное содержимое `.scr` сохраняется reader-ом, но
-остаётся вне этого исполнения.
+отсутствующие, выходящие за диапазон или не-`DWORD` цели. Общий для первого
+события метод `VarSet::resolve_init` добавляет этот `Handler(19)` и формулу из
+одного числового или переменного атома либо двух таких атомов с одним бинарным
+`+` или `-`. Остальные формы инструкции и содержимое `.scr` сохраняются
+reader-ом, но остаются вне этого исполнения.
+
+### Начальная группировка подготовленных Control-дескрипторов
+
+В проверенной GOG `Behavior.dll` (SHA-256
+`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`)
+`FUN_1001be10` (RVA `0x1be10`) получает список через QI с кодом `0x202` от
+`owner+0x5c`. Слоты `+0x50` и `+0x1c` этого интерфейса возвращают число строк и
+код-различитель для строки; цикл обрабатывает порядковые номера строк
+`0..count-1` по возрастанию. Метки `1`, `2` и `30` начинают группу, добавляют
+кандидата и добавляют отдельный элемент к последней группе соответственно.
+Запись с меткой `2` до первого заголовка попадает в отдельный вектор; метка `30`
+без группы игнорируется, как и все остальные метки.
+
+Для источника списка проверена пара GOG DLL. В `Control.dll` SHA-256
+`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` запрос с
+кодом `0x202` отдаёт интерфейс `owner+8`; его методы-обёртки переходят к методам
+чтения канонического QI `0x204`, receiver которого равен `owner+0x0c`. Метод
+`Control.dll` RVA `0x8830` читает число из `[receiver+0x5b4]`, то есть
+`[owner+0x5c0]`; таблица указателей находится в `[receiver+0x5b0]`, то есть
+`[owner+0x5bc]`. Поле `[owner+0x5b4]` — отдельный указатель кэша, а не число
+строк. Метод чтения `Control.dll` RVA `0x2c390` выбирает указатель `table[index]`,
+разыменовывает указатель на дескриптор из записи `+0x48` и возвращает DWORD по
+адресу этого дескриптора. Загрузчик `FUN_10021d50` (RVA `0x21d50`) сохраняет там
+указатель текущего дескриптора, поэтому код-различитель — его текущая метка, а
+не неизменная метка фабрики.
+
+В оригинальном Behavior внешняя строка имеет шаг `0x34`; её `+4` получает
+порядковый номер заголовка. Строка-кандидат имеет шаг `0x58`, а её `+8` хранит
+порядковый номер кандидата. Номера строк с меткой `30` добавляются в отдельный
+вектор последней внешней строки. Rust публикует только эти начальные номера:
+`MissionBehaviorControlGroup` и
+`MissionBehaviorControlGrouping` группируют строки из уже подготовленной
+Control-таблицы в её исходном порядке. `MissionBehaviorControlGroupingState`
+сохраняет `original_id`, `ObjectHandle` и либо результат, либо исходную причину
+недоступности Control-таблицы. Полная загрузка создаёт проекцию перед публикацией
+`LoadedMissionState`; предварительный просмотр возвращает пустой список. Проверки
+`behavior_control_grouping_uses_current_tags_and_table_ordinals` и игнорируемый
+`full_load_builds_initial_behavior_control_groupings_for_autodemo` покрывают
+искусственные граничные случаи и сохранённые номера строк AutoDemo соответственно.
+
+Это начальное разложение подготовленного списка, а не реконструкция полной
+инициализации Behavior: отдельные свойства строки-кандидата, её числовые поля,
+эффекты и обновление списка после изменений Control здесь не моделируются.
### Изменение provider-состояния Control
@@ -912,6 +1158,71 @@ factory-класса. В Rust неизвестный текущий тег за
CPU-вызов, ни локальные векторы не означают включения бюджетного шага в Control
clock.
+#### Глобальный Control getter: сырой селектор `0x0f`
+
+В `Control.dll` SHA-256
+`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`
+`FUN_1002b410` (RVA `0x2b410`) принимает сырой селектор в `EDX` и указатель
+результата из `[ESP+4]`; общий выход использует `RET 4`. Сырой селектор `0x0f`
+попадает в case RVA `0x2b8f5`. Владелец хранит ссылки на объекты, добавленные
+фабриками `9` (`owner+0x5cc`) и `21` (`owner+0x5d0`).
+
+Фабрика `21` вызывает общий getter `FUN_10021d00` (RVA `0x21d00`): selectors
+`0..5` читают шесть текущих `coefficient_bits` по `this+0x54`. Общий загрузчик
+RVA `0x21d50` первоначально копирует descriptor words `[11..16]` в это поле. Фабрика `9`
+использует getter `FUN_100255c0` (RVA `0x255c0`), который для selector `0`
+умножает коэффициент `[this+0x54]` на множитель `[this+0x11c]`. Поэтому Rust
+выбирает последнюю строку каждого `factory_tag` `9` и `21` в исходном порядке
+таблицы; текущий `descriptor.tag` не меняет выбранную фабрику. Если любой строки нет,
+результат отсутствует до проверки полей другой строки.
+
+Арифметика case `0x0f` сохраняет порядок и отдельное округление каждого шага:
+
+```text
+sum = f32(D5)
+for k = 4, 3, 2, 1, 0:
+ sum = f32(sum + Dk)
+out = f32(C0 * sum)
+```
+
+`Dk` — текущий коэффициент `k` из `coefficient_bits` фабрики `21`; `C0` —
+selector `0` фабрики `9`, то есть
+`coefficient_bits[0] * class9_selector0_scale_bits`. Общие `multiplier_bits`, `node_provider_status` и
+`native_word_50_bits` в этом обратном вызове не читаются. Rust проверяет, что
+входы и промежуточные результаты конечны и являются обычными числами либо
+точным нулём; переполнение, денормализованные числа и отсутствующие поля
+остаются типизированными ошибками. Диапазон
+проверяется через `f64`, а затем операция выполняется в `f32` с округлением
+после каждого сложения. Это воспроизводит описанные записи `f32`, но не
+моделирует полное управляющее состояние x87. Этот getter не реализует весь Control
+metadata `0x36`, Handler `43` или каталог свойств.
+
+Лицензионный acceptance-test загружает `MISSIONS/Autodemo.00/data.tma` с явным
+`MissionStartupInput { queue_time_ms: 100_000, research_creation_gate: false }`
+и вызывает публичный `query_loaded_mission_control_resource(..., 15)`. Native
+вызов исходного `Control.dll` того же SHA выполнен с `executionCW=0x007f` и
+получил следующие биты на Rust-подготовленном снимке входных полей:
+
+| `original_id` | Результат selector `0x0f` |
+| --- | --- |
+| `0` | `0x46572800` |
+| `1` | `0x4636d000` |
+| `2` | `0x467b4001` |
+| `3` | `0x468ca000` |
+| `4` | `0x46ec4001` |
+| `5` | `0x46ec4001` |
+
+Для `original_id` `6` и `7` таблица Rust не содержит обоих требуемых объектов
+фабрик (`factory_tag` `9` и `21`), поэтому публичный запрос возвращает `None`.
+Исходный обратный вызов для этих двух объектов отдельно не запускался; ветвь
+неуспеха native getter-а проверена на отдельном векторе с отсутствующим указателем.
+
+Сравнение шести значений проверяет формулу getter-а на исходных игровых ресурсах
+и снимке входных полей, подготовленном Rust. Оно не снимает состояние фабрик,
+созданных оригиналом. Время и флаг `queue_time_ms` и
+`research_creation_gate` задаёт тест, поэтому эти значения не подтверждают
+начальное состояние запуска AutoDemo в оригинале.
+
#### Фабричный tag 26: изменяемая добавка
В этой сборке `Control.dll` SHA-256
@@ -1099,18 +1410,36 @@ payload. Позже та же функция `World3D+0x7cb0` повторяет
времени из `DATWorld+0x32a38` (`0x7e6e`); `World3D+0x7ea0` передаёт туда же
время в `0x80a9`. Setter `World3D+0x5140` записывает входной DWORD в эту ячейку.
Нулевой вызов связывает интерфейсы, а ненулевой `NumericTime` запускает отдельную
-ветвь активации. Конструктор AniMesh задаёт `+0x158/+0x15c/+0x160` равными `1.0`.
-Обработчик модели `0x75b0/0x6fc0` привязывает интерфейсы и матрицу, Effect RVA
-`0x3d30` и Control RVA `0x1ce00` сохраняют host/service указатели, а Control
-`0x1fe00` меняет только локальный collision-флаг. Эти ветви не вызывают AniMesh
-scale setter `0x14770` и не пишут поля масштаба. При загрузке AniMesh получает
-Control secondary owner с QI selector `0xa` и сохраняет возвращённый base owner
+ветвь активации. Модель занимает отдельный объект в памяти: её конструктор
+AniMesh RVA `0x68a0` задаёт `Model+0x158/+0x15c/+0x160 = 1.0`.
+Поле `Agent+0x158` хранит указатель на Control. Проверенные ветви модели
+`0x75b0/0x6fc0`, Effect RVA `0x3d30` и CollManager RVA `0x1ce00` сохраняют
+матрицу и служебные указатели, оставляя масштаб модели неизменным.
+
+Фабрика CollObject возвращает вторичный интерфейс `object+4`. QI `0x28`
+вычитает `4` и сохраняет в `Agent+0x170` основной объект с vtable `0x3c2fc`.
+Его event slot `+0x08` ведёт в Control RVA `0x1f530`. При event `4/null`
+обработчик кэширует семь host-интерфейсов; при наличии QI `0x203` вызывает
+CollManager slot `+0x10` таблицы `0x3c178`. Этот метод, RVA `0x1c330`,
+сохраняет только указатель `CollObject+0x20` в `CollManager+0x3c`.
+Для свежего BULL Agent без внешнего host QI `6` при привязке даёт основной Agent
+с vtable `0x201d4`. Ненулевой event `4` вызывает его slot `+0x24`, AniMesh
+RVA `0x17d60`, который читает `[Agent+0x120]`; вызывающий код сохраняет
+результат в `CollObject+0x24`.
+Затем CollObject slot `+0x0c`, Control RVA `0x1fe00`, сбрасывает бит `0`
+поля `CollObject+0x30`. Event `0x15` с первым DWORD payload `9` завершается
+без вызовов и записей. Поле Control `owner+0x34` хранит указатель на CollObject;
+его slot `+0x14`, RVA `0x1f670`, меняет флаги `CollObject+0x30`.
+Перечисленные ветви не вызывают AniMesh setter `0x14770`.
+
+При загрузке AniMesh получает Control secondary owner с QI selector `0xa`
+и сохраняет возвращённый base owner
в `Agent+0x158`: `AniMesh.dll+0x2ff0` делает этот вызов в RVA `0x32ee..0x3300`,
а `Control.dll+0x76f0` для selector `0xa` вычитает `0x14` из `ECX` и возвращает
указатель. Диспетчер AniMesh RVA `0x1afb..0x1b0d` посылает через `Agent+0x158`
событие `4` в slot `+0x08`. Его base-owner vtable — Control RVA `0x3d298`, и
slot ведёт прямо в `0x314f0`; при нулевом payload исходный обработчик проходит
-`0x7830 -> 0xf940`, поэтому FAC0 входит с constructor scales `[1, 1, 1]`.
+`0x7830 -> 0xf940`, поэтому FAC0 получает Model scale `[1, 1, 1]`.
Встречающиеся рядом переходники относятся к другим интерфейсам: Control owner
`+4` с таблицей `0x3d254` проходит через `0x31ae0` (adjust `-4`), а owner `+8`
с таблицей `0x3d1fc` — через `0x31b00` (adjust `-8`). Последний путь не является
@@ -1166,6 +1495,77 @@ descriptor word `8`. Текущий tag `26` тем же общим reader-ом
умножает на `M`. Это правило не меняет отдельные callbacks бюджета или запись
factory-26 selector `0x500`.
+Сырой selector `6` в `FUN_1002bb40` выбирает устройство по ordinal через
+интерфейс `owner+0x0c`: таблица `this+0x5b0` соответствует `owner+0x5bc`.
+RVA `0x2bb91` проверяет тег текущего descriptor на равенство `2`. При совпадении
+RVA `0x2bb9a` загружает `device+0x174` как `f32`, сохраняет значение в выходной
+буфер и возвращает `AL=1`/`RET 8`; другие текущие теги возвращают false.
+Наличие поля `+0x174` подтверждено для экземпляра фабрики `2`. INTO сохраняет
+исходный тип экземпляра, поэтому проверка тега descriptor отдельно не доказывает
+наличие этого поля у другой фабрики. Rust selector `6` сначала проверяет
+текущий descriptor tag: тег не `2` даёт `Ok(None)`; затем требуется известная
+фабрика `2` и конечное нормальное число либо ноль любого знака в `+0x174`. В
+`Control.dll` (SHA-256
+`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) поле
+получает ноль в конструкторе по RVA `0x294c0`; чтение, сравнение пары строк и
+пересчёт находятся по RVA `0x2bb40`, `0x29940` и `0x296f0`.
+
+Typed record хранит две ASCII C-строки в words `[27..35)` и `[35..43)`. Строка
+заканчивается на первом NUL: байты за ним игнорируются, а регистр при сравнении
+не учитывается. Если новая, изменённая пара содержит пустую строку, native
+recalc завершается до `CreateObject` и selector `35`, поэтому сохраняются
+прежние биты. Пустая пара конструктора уже совпадает с текущей и вообще не
+запускает recalc. Иначе append и первая активная запись `INTO` применяют
+результат в исходном порядке. Для нераспознанной формы поле остаётся
+недоступным; ошибка разбора распознанного, но повреждённого ресурса прерывает
+новую загрузку до публикации.
+
+В общем случае native selector 35 не равен сумме сырых Max:
+`Control.dll` getter `0x0dc40` (selector 3) читает Max по `descriptor+4`, где
+указатель descriptor лежит в начале runtime provider row, затем умножает его
+на масштабы владельца `+0x660` и `+0x548`. Функция `0x13620` складывает выходы
+в порядке строк с округлением `f32`; при `row+0x24 != 0xffffffff` добавляется
+индексированный вклад. Конструктор `0xb240` задаёт runtime `row+0x24`
+служебное значение `0xffffffff`. Только в свежей форме поддержанного BULL оба
+масштаба равны `1`, а все `row+0x24` равны
+`0xffffffff`, поэтому формула сводится к NDP Max сумме.
+
+Пересчёт создаёт временный объект запросом `9` через `World3D.dll+0x7cb0`.
+Загрузчик `Control.dll+0x32280` формирует для его корня входной контекст с
+полем `+0xc8 = 0xffffffff`. Для разрешённых opcode `1` и `12`, а
+также `4` только после FXID/CPT-проверки, подтверждённые callbacks сохраняют
+входы суммы: `Control.dll` RVA `0x43d0` меняет только локальные слова; в
+`AniMesh.dll` (`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`)
+RVA `0x5500/0x93c0` меняют узлы и список модели, а `0x5320` не пишет её
+масштабы. Это статическая граница свежего пути, не снимок живого native World
+после всех creation callbacks.
+
+Проверка opcode `4` читает только выбранные строки и принимает FXID-типы
+`2/3/4/5/7/8/9` с распознанными ASCII-ссылками и CPT offsets внутри компонента;
+типы `1/6/10`, отсутствующие ресурсы и непроверенные offsets оставляют поле
+недоступным. В `Effect.dll` (SHA-256
+`386a3e8db1ce23f61957825d633c7dae1ed40c25cab0cf10a534385fd7cee789`) проверены
+инициализаторы типов `2/3/4/5/7/8/9` по RVA `0x12d10/0x103b0/0x108c0/0x28a0/
+0x1720/0x11230`; тип `9` разделяет путь типа `3`.
+
+`AssetManager::initial_control_effect_opcodes` собирает исходный порядок кодов
+выбранной начальной группы до фильтра opcode `4`. `Some([])` означает, что
+по структуре данных команд нет: группа отсутствует, пуста или её беззнаковый
+индекс не меньше числа групп. Условия отдельных строк этот читатель не
+вычисляет. `None` означает неподдержанный либо
+неполный формат, а повреждённая структура возвращает ошибку. Такая разница не
+позволяет принять неизвестный разбор за пустую сумму.
+
+Ресурсные callbacks в этой цепочке привязаны также к следующим образам.
+Записи палитры и звуковые либо графические буферы имеют отдельное хранение;
+проверенные начальные вызовы не меняют входы суммы NDP.
+
+| Модуль | SHA-256 | Существенные RVA |
+|---|---|---|
+| `World3D.dll` | `17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e` | `0x7cb0` — создание; `0x4b10/0x4cb0` — текстуры и lightmap |
+| `Ngi32.dll` | `bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5` | `0x11f80` — чтение ресурса; `0x11410` — счётчик палитры; `0xd620/0xe900` — звуковые буферы |
+| `Terrain.dll` | `af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` | `0x166a0/0x17ac0` — получение указателя на числовое поле World |
+
FCA0 передаёт в owner query порядковый номер `i`; query `0x2bb40` находит
устройство `i` и вызывает его reader. Уже reader `FUN_10021d00` получает provider
key из поля устройства `+0x04`. Provider getter `FUN_1000dc40` (RVA `0x0dc40`)
@@ -1173,6 +1573,80 @@ key из поля устройства `+0x04`. Provider getter `FUN_1000dc40` (
строку `table + key*0x2c`; текущее type-1 значение находится по `+0x14` строки.
Индекс устройства и ключ provider row — разные значения с разными задачами.
+#### Индексный Control getter: сырой селектор `0x400`
+
+В той же сборке `Control.dll` с SHA-256
+`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`
+индексный query для `QueryInterface(0x204)` входит через слот `+0x18` vtable
+`0x3d1b4`: `FUN_1002bb40` (RVA `0x2bb40`, `RET 8`, `ECX=owner+0x0c`, ordinal
+устройства в `EDX`, selector и output pointer на стеке). При сыром selector
+`0x400` функция попадает в ветвь RVA `0x2bc3c`.
+Ветка получает provider pointer из `device+0x44`, отдельно читает provider key
+из `device+0x04` и вызывает provider vtable slot `+0x0c` в режиме `1`, передавая
+адрес ячейки selector как storage результата. После вызова значение переносится
+в выходной `f32` caller-а; ветвь возвращает `AL=1` без проверки текущего
+descriptor tag или factory tag.
+
+Поле `device+0x44` указывает на provider interface `owner+0x04` с vtable
+`0x3d254`; slot `+0x0c` ведёт к `FUN_1000dc40` (RVA `0x0dc40`). Getter
+использует key для строки общей таблицы, а не ordinal устройства. Для валидной
+строки selector `1` читает её текущее type-1 поле `+0x14` напрямую:
+`0x400` поэтому не пересчитывает долю из amount и maximum и не умножает её на
+коэффициент устройства, `M` или descriptor word. В Rust тот же маршрут выглядит
+так:
+
+```text
+device = devices[device_index]
+value = providers[device.key].current_type1_value
+return Some(value)
+```
+
+Индекс устройства сначала выбирает запись, её key затем выбирает provider row;
+этот indexed-вызов не является глобальным запросом `0x0f` и не является
+metadata getter IID `0x16`. Проекция Rust принимает конечные нормальные значения
+и точные `+0`/`-0`, сохраняя биты нулевого значения; остальные float-диапазоны и
+недоступные table/key остаются типизированными ошибками. Native selector-cell
+alias/fallback вне валидной записи provider не реализован: Rust не подставляет
+для такого пути `0.0` или `None`.
+
+Шесть изолированных вызовов оригинала проверяют именно этот валидный
+indexed-вызов.
+В каждом ordinal устройства равен `2`, а ключ provider равен `1`, `3` или `4`;
+все значения строк различаются, поэтому ключ не совпадает с ordinal и не
+подменяется соседней строкой.
+
+| Вектор | Ordinal | Key | Raw output | AL |
+| --- | ---: | ---: | ---: | ---: |
+| Значение из корпуса `1.0` | 2 | 1 | `0x3f800000` | 1 |
+| Доля `0.625` | 2 | 1 | `0x3f200000` | 1 |
+| Отрицательное конечное `-2.5` | 2 | 3 | `0xc0200000` | 1 |
+| `+0` | 2 | 1 | `0x00000000` | 1 |
+| `-0` | 2 | 3 | `0x80000000` | 1 |
+| Отличающаяся строка `0.0625` | 2 | 4 | `0x3d800000` | 1 |
+
+Вектор `0.625` специально задаёт native word `device+0x50 = 0`; он всё равно
+возвращает текущее поле provider. Таблица устройства, provider и остальные
+строки были синтетическими, ограждёнными входами; исполнены оригинальные
+`FUN_1002bb40` и provider getter `FUN_1000dc40`, без constructors, ControlSystem
+или mock callbacks. Все входные записи остались неизменными, проверены canary
+области, а изменился только DWORD выхода. Для DLL с SHA-256
+`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` входной
+control word `0x027f` был временно заменён на `0x007f` и восстановлен после
+вызова; у всех шести ветвей `AL=1`. Выходы совпали с прямыми значениями выбранных
+synthetic rows. Это не снимок состояния оригинальной фабрики или миссии: полный
+IID `0x204`, интеграция Behavior и gameplay-трасса не проверены.
+
+Независимый разбор лицензированного AutoDemo дал восемь таблиц и 100 пар
+устройство/key, но все выбранные type-1 значения там равны `1.0`. Такой набор
+проверяет загрузку таблицы, ключи и доступность запросов, но не позволяет
+различить выбор строки по key и по ordinal. Игнорируемый Rust acceptance test
+проверяет все 100 запросов на этом корпусе и ожидает `0x3f800000`. В тесте явно
+заданы `MissionStartupInput { queue_time_ms: 100_000,
+research_creation_gate: false }`; эти значения являются входами Rust-проверки,
+а не доказательством штатного gate оригинала. Native синтетические строки дают
+отдельное доказательство выбора key. Оба результата имеют ограниченную область
+и не являются сравнением полного поведения игры.
+
Конструктор owner `FUN_10006bf0` (RVA `0x6bf0`) задаёт scalar `+0x1a8 = 1.0`
и вызывает `FUN_10014260`, который записывает `1.0` в `+0x354/+0x358`.
Проверенные ветви канонического event `4/null` и FAC0 не меняют эти три поля
diff --git a/docs/appendices/ui-shell.md b/docs/appendices/ui-shell.md
index d236e14..58d4083 100644
--- a/docs/appendices/ui-shell.md
+++ b/docs/appendices/ui-shell.md
@@ -26,10 +26,103 @@ Win32 messages
`sprites.lib` и `Palettes.lib`. Конфигурации задают controls/resources/cursor,
overlays и HQ; `TextRes.cfg` связывает символические ключи с текстом.
-`gamefont.rlb` содержит две LZSS-записи (`0x040`), `sprites.lib` — 24 записи
-raw Deflate (`0x100`). `sprites.lib::INTERF8.TEX` использует известный
-`deflate_eof_plus_one` quirk. UI обязан пользоваться общим RsLi reader, а не
-отдельной похожей распаковкой.
+В demo `gamefont.rlb` содержит две LZSS-записи (`0x040`); `sprites.lib`
+содержит 24 записи raw Deflate (`0x100`) и известный `deflate_eof_plus_one`
+quirk для `INTERF8.TEX`. В полной GOG-сборке меню отдельно связывает
+`ui/menu_resources.cfg` с `ui/font.lib`, архивом NRes. Поэтому `gamefont.rlb`
+не задаёт единственный путь шрифта. Оба контейнера уже читаются общими
+[RsLi](../reference/rsli.md)- и [NRes](../reference/nres.md)-readers проекта;
+будущая UI-подсистема должна пользоваться ими, а не добавлять свою распаковку.
+
+В `menu_resources.cfg` ресурс `fonts` указывает `ui\\font.lib` с `libtype=multi`
+и `type=2`; это поля CFG, а не FourCC записей NRes. У девяти записей
+`font.lib` `type_id` равен `0x544E4F46` (`FONT`). Значение подстановки в
+`font-substitute` — строковое имя: этот CFG alias (`MENU_FONT` или `GAME_FONT`)
+связывает роль шрифта с его именем, но сам не является именем записи архива.
+Числовые ссылки находятся отдельно, в объекте `fonts`. В проверенной паре CFG и
+архива эти ссылки совпадают с исходными порядковыми номерами записей NRes
+(`EntryId`), а не с `sort_index`:
+
+| Размер CFG | Строковый alias `font-substitute` | Ссылка `fonts` | `EntryId` и запись NRes |
+| --- | --- | --- | --- |
+| `640x480` | `MENU_FONT` | `8` | `8`, `mf_640.tft` |
+| `640x480` | `GAME_FONT` | `6` | `6`, `gf_640.tft` |
+| `800x600` | `MENU_FONT` | `5` | `5`, `mf_800.tft` |
+| `800x600` | `GAME_FONT` | `3` | `3`, `gf_800.tft` |
+| `1024x768` | `MENU_FONT` | `2` | `2`, `mf_1024.tft` |
+| `1024x768` | `GAME_FONT` | `0` | `0`, `gf_1024.tft` |
+
+Пример из этой CFG: `font-substitute` связывает `MENU_FONT` со строкой
+`MENU_FONT_1024x768`, а объект `fonts` связывает уже это имя с числом `2`.
+Такое сопоставление относится только к этой конфигурации и архиву, а не является
+универсальным правилом native selector. Строка `1024x768` не доказывает выбор
+этого варианта при фактическом экране `1280x1024`.
+
+Проверены оригинальные `menu_resources.cfg` (SHA-256
+`508C3783EAF112023E46BF4617424FBB57C393BCEF328CE1B925549B0069EF97`) и
+`font.lib` (440392 bytes, SHA-256
+`E4AF5D1F0BF0464574DE654DE378766090681F610705CE53539DF9041E045D1C`).
+Шесть сопоставленных payload начинаются с `Tfnt`; это подтверждает сигнатуру
+данных, но не значение последующих полей.
+
+Связка подтверждена и в оригинальном коде. В `iron3d.dll` SHA-256
+`D1DC4EA8535E2069B0A05C9E8BE2724DD438BA44CCA7A83007D8B2E50F9E35FA` shell
+читает `shell_ctrls.cfg` в RVA `0x7410`, а инициализация в `0x78c0` читает и
+регистрирует `menu_resources.cfg` в слоте `0` ResourceManager. `services.dll` SHA-256
+`D3619DEEB4D598B16099E0263E15F9C3213F0FAA7208DC093328CFBA93FDC0EA`, RVA
+`0x92a0`, разбирает `font-substitute`, размерные значения и запись ресурса
+`type=2`. Затем `iron3d.dll` запрашивает `MENU_FONT` в RVA `0x1eb20`, ищет ресурс
+в `0x1ed06` и разыменовывает результат в `0x1ed3e`; прямая x86-трасса на
+последнем участке подтвердила ненулевой объект. Это подтверждает получение
+реального font-объекта, но не конкретной NRes-записи при `1280x1024`, её
+отрисовку или паритет шрифтов.
+
+## Фиксированный reader Tfnt
+
+В подтверждённом пути `World3D.dll` (SHA-256
+`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`)
+передаёт ordinal NRes-reader-у в RVA `0x1361f`, а затем передаёт полученный
+указатель renderer-у в RVA `0x13624`; бит `0x400000` установлен у вызова в RVA
+`0x1360d`. В `Ngi32.dll` (SHA-256
+`bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5`) vtable
+NRes у RVA `0x318bc` ведёт к `FUN_10011f80` (RVA `0x11f80`), которая возвращает
+`mapped_base + data_offset`. Renderer vtable у RVA `0x315e0` направляет slot
+`+0x58` в `FUN_10007dd0` (RVA `0x7dd0`), затем ресурс проходит через
+`FUN_10010c10` (RVA `0x10c10`) и хранит pointer в `FUN_1000f3d0` (RVA `0x0f3d0`).
+Loader font-объекта `FUN_1000fb30` (RVA `0x0fb30`) при флаге `0x400000`
+использует этот адрес как начало Tfnt payload; отдельная RsLi-обёртка в этом
+пути не читается.
+
+| Смещение | Что читает `fparkan-font` |
+| --- | --- |
+| `0x00..0x03` | Сигнатура `Tfnt`; reader проверяет её на границе файлового API. В наблюдавшемся native loader проверки сигнатуры не было. |
+| `0x04..0x07` | `i32`, нижняя граница ширины в native fixed-width path. |
+| `0x08..0x0b` | `i32`, вертикальный размер для native clipping/drawing. |
+| `0x0c..0x0f` | Сырые биты `u32`, скопированные в font object и участвующие в float-сложении при drawing; имя и смысл поля неизвестны. |
+| `0x10..0x13` | `i32`, прибавляемый один раз на входной байт при native text measurement. |
+| `0x14..0x1013` | 256 записей по 16 байт: три сырых DWORD и signed `i32` advance по смещению `+0x0c`. |
+| `0x1014..конец` | Обычный Texm-документ, передаваемый общему `fparkan-texm::decode_texm`. |
+
+Первые три слова glyph record имеют разные native consumers, но их общая
+семантика не восстановлена: одно слово загружается как float, другое попадает
+в сформированные vertex data, а третье не прояснилось в исследованных ветвях.
+Поэтому reader сохраняет их как `word0..word2`, а поле заголовка по смещению
+`0x0c` — как неинтерпретированные float-биты. Он не выбирает размер текстуры по разрешению
+дисплея и не задаёт диапазон размеров: в проверенных ресурсах встречаются
+`128×128` и `256×256`, а форматы, mip-цепочку и палитру проверяет общий Texm
+reader.
+
+```rust
+let font = decode_tfnt(Arc::<[u8]>::from(raw_font_payload))?;
+let glyph = font.glyphs[usize::from(input_byte)];
+let texture = font.texm;
+```
+
+Игнорируемый corpus-тест разбирает через общий NRes reader девять записей
+`ui/font.lib` и `ARIALTEX.TFT` из `sys.lib`; он сверяет известные размеры,
+Indexed8, одну mip-ступень и выборочные поля `gf_640.tft`. Это проверка байтов
+лицензированной GOG-сборки и parser contract, а не запуск shell или проверка
+отрисовки.
Ключ ресурса, legacy path, локализованный текст и исходные bytes — четыре
разные идентичности. Archive keys остаются ASCII-casefold; `TextRes.cfg`,
@@ -49,10 +142,11 @@ design resolution. Отдельно хранятся legacy-layout coordinates,
viewport, scale/letterbox policy, mouse-to-layout transform, clipping и
z-order. Cursor, sprite и hit-test обязаны применять одно преобразование.
-Font contract включает glyph image, advance, bearing/offset, line height и
-fallback glyph. Binary glyph metrics `gamefont.rlb` пока не восстановлены:
-payload читается lossless через RsLi, а семантика устанавливается по consumer
-trace.
+Font contract включает glyph image, bearing/offset, line height и fallback
+glyph. `fparkan-font` читает фиксированную таблицу Tfnt и отдаёт signed advance
+и embedded Texm; он не выполняет text measurement/layout или glyph drawing.
+`gamefont.rlb` по-прежнему читается общим RsLi reader, но его native consumer не
+установлен.
Нормализованное событие проходит modal widget, затем shell command map; лишь
разрешённая gameplay-команда превращается в World3D manual event. Held state и
diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md
index 3267a2f..45d7725 100644
--- a/docs/reference/environment-effects.md
+++ b/docs/reference/environment-effects.md
@@ -236,11 +236,19 @@ packed: [key.colors[10], key.colors[14]],
`AtmosphereFrame` пока не несёт этот бит обновления, поэтому sampler
выдаёт интерполированный цвет при каждом запросе.
-## Экранная геометрия солнца и луны
+## Отрисовка солнца, луны и бликов
-`CSun::Render` сначала переводит направление небесного тела в пиксели текущего
-viewport, затем собирает quad с native XYZRHW-позицией. При положительной высоте
-viewport Vulkan пиксели переводятся в clip-space так:
+`CSun::Render` получает текущий viewport, проецирует направление солнца или
+луны и собирает quad уже в его пикселях. `Terrain.dll` VA `0x1004AA40` передаёт
+эту геометрию через shade callback; native FVF `0x1c4` задаёт
+`XYZRHW | DIFFUSE | SPECULAR | TEX1`. Конвертер `0x10020700` сохраняет входные
+`x`, `y` и `z`, а в `RHW` записывает `1.0`; исходный `z` равен `0.999`. Callback
+включает depth test и выключает запись depth, поэтому более близкая поверхность
+может закрыть спрайт, а сам спрайт не меняет глубину для последующих слоёв.
+
+В Vulkan ряды 3 и 4 сохраняют этот contract отдельным bit 8 material mode.
+CPU каждый кадр преобразует viewport-пиксели к clip space текущего размера
+swapchain:
```rust
position = [
@@ -250,49 +258,102 @@ position = [
];
```
-Vertex shader пропускает `clip_from_world` для строк солнца и луны. CPU каждый
-кадр пересчитывает центр и размер quad по текущему viewport; старые пиксельные
-координаты не переносятся на новый размер. World-space billboards бликов
-остаются отдельным путём. Материал экранного quad не освещается, vertex fog
-отключён, а sky far-depth bit к нему не применяется.
-
-Native `z = 0.999` не означает, что спрайт гарантированно лежит за любой
-поверхностью. Vulkan использует `LESS_OR_EQUAL` и не записывает depth для
-прозрачного ряда. При near `0.5`, far `700` мировая поверхность на расстоянии
-`320` получает depth
-`700 / (700 - 0.5) * (1 - 0.5 / 320) ≈ 0.999151`; поэтому экранный `0.999`
-чуть ближе этой поверхности и проходит depth test перед ней. Это фиксированное
-native значение сохраняется для совместимости, но само по себе не обещает
-окклюзию солнца всей геометрией местности.
-
-## Геометрия визуальных FX-команд
-
-`fparkan-fx::fx_visual` формирует кадры для двух подтверждённых вариантов
-`f_gener_ball`. Op4 принимает mode 0 с положительным знаменателем в `body+0xC4`
-и выдаёт camera-facing quad; расстояние камеры считается через native
-component-wise projection родительской матрицы. Op9 принимает mode 2:
-селектор `body+0xC4` со значением 0, 1 или 2 отображается в native flag base
-3, 5 или 6 и выбирает одну из трёх сеток; категория render flags выбирает её
-детализацию. `body+0xC8` выбирает UV path:
-нулевое значение проецирует позицию вершины на XY, ненулевое использует
-фиксированные координаты quad и cap. Кадр сохраняет MAT0 resource, фазу,
-opacity, вершины и triangle indices для renderer.
-
-Evaluator получает готовые command body, phase, camera point и внешнюю
-матрицу. Выбор Control row, manager gates, owner attachment, header jitter и
-global RNG остаются задачей владельца. `fparkan-game` пока использует для Op9
-категорию 0 с наибольшей детализацией; выбор категории по projected size ещё
-не реализован.
-
-Для Op3 поддерживаются body-типы 0, 1 и 2. Поля отсчитываются от
-`FxCommand.raw_body`, то есть после 4-байтового command word. Типы 0 и 1
-используют общие position/scale/phase/opacity tracks и camera-facing основу;
-тип 1 и тип 2 также интерполируют ось, а тип 2 строит basis через native
-D110-порядок. Все три evaluator-а принимают уже собранную матрицу владельца и
-не выбирают Control row или attachment. Текущий путь для типов 0 и 1
-предполагает native render context 0: `Effect.dll` проверяет общий gate
-`context + 0x60 < 8`, а API этих evaluator-ов не выбирает другой context.
-Evaluator типа 2 принимает индекс context явно и учитывает этот gate в `active`.
+Положительная высота Vulkan viewport отображает эти координаты в тот же
+top-down экран. Vertex shader пропускает мировую матрицу только для этого
+режима:
+
+```glsl
+vec4 clip_position = screen_tl
+ ? vec4(in_position, 1.0)
+ : frame.clip_from_world * vec4(in_position, 1.0);
+```
+
+Материал остаётся unlit, fog factor равен нулю. Для мировой геометрии
+оригинал задаёт viewport `MinZ = 0.1`, `MaxZ = 0.99`: после отсечения
+проекционная глубина отображается в этот диапазон:
+
+```text
+world_depth = 0.1 + (0.99 - 0.1) * (clip_z / clip_w)
+```
+
+Даже дальняя гора получает глубину не больше `0.99` и закрывает экранный
+диск с `z = 0.999`. Если оставить мировой диапазон `0–1`, при near `0.5`
+и far `700` поверхность дальше примерно `292` единиц окажется позади диска:
+солнце начнёт просвечивать сквозь неё при движении камеры. Одного переноса
+нативного `z` в экранные вершины недостаточно.
+
+В Vulkan диапазон задаётся динамическим viewport отдельно для мировых
+пакетов; экранные TL и фоновые sky-пакеты сохраняют диапазон `0–1`.
+Матрица проекции и плоскости отсечения при этом не меняются. Константы
+мирового профиля записывает GOG `Terrain.dll` RVA `0x27422–0x27452` из таблицы
+`0x65088/0x6508C/0x650A0/0x650A4` (near, far, MinZ, MaxZ). GOG `Ngi32.dll`
+RVA `0x7170`, участок `0x71E7–0x723C`, копирует MinZ/MaxZ в renderer
+`+0x30/+0x34` и передаёт их `IDirect3DDevice7::SetViewport`; сборки указаны в
+[таблице бинарников](original-binaries.md). Нативные значения этих двух полей
+имеют биты `0x3DCCCCCD` и `0x3F7D70A4`.
+
+Sky-background bit 4 продолжает помещать слои на дальнюю плоскость. Отдельные
+flare rows 5 и 6 пока используют world-space billboard builder: для них не
+подтверждён этот же native TL путь.
+
+## Эффекты Control
+
+Погодное расписание `sky.ske` — только один источник эффектов. У объектов
+миссии таблица Control (`.ctl`) связывает строки с FXID-командами. Текущий
+preview выбирает начальную группу Control и исполняет только строки без
+predicate, chain и state gate. Условия игровых событий и переходы между
+группами принадлежат полному игровому Control runtime и здесь не моделируются.
+
+Type 4 row задаёт локальную basis эффекта через три точки CTL point list. Для
+валидной привязки первая точка также задаёт owner node. Путь MSH выбирает позу
+этого узла из текущей pose hierarchy; для внешнего mounted-компонента к ней
+добавляется поза socket. После этого применяются object transform, header
+scale и локальная матрица FX. Подробнее о порядке матриц см.
+[описание MSH](msh.md#control-fx-и-owner-transform).
+
+Renderer preview поддерживает FXID-команды Op3 modes 0–2, Op4, Op7, Op8 и Op9.
+Op3 mode0 строит camera-facing поверхность из анимируемых локальных позиции и
+масштаба; Op7/Op8 обновляют отдельные элементы particle pool; Op9 создаёт
+индексированную полусферическую сетку. Op1 вычисляется отдельно как список
+point lights: selector 1 и записи без `flags & 0x80000000` входят в общий
+diffuse light set. Записи с этим битом сохраняют native flags для специальных
+путей и projected shadows, но не добавляются в общий diffuse список.
+
+Body discriminator Op3 читается из первого DWORD `FxCommand.raw_body`; offsets
+в evaluator-е относительны к данным после четырёхбайтового command word. Mode 1
+добавляет анимируемую ось к camera-facing basis, mode 2 использует native
+D110 basis. Для modes 0 и 1 preview предполагает render context 0, который
+проходит общий gate `Effect.dll` `0x106C0`; их API не выбирает другой context.
+Evaluator mode 2 принимает индекс context и учитывает gate при расчёте `active`.
+
+У Op3 mode 1 ось задаёт длину прямоугольника, а не его нормаль. Для
+локального вектора к камере `v` и анимируемой оси `a` столбцы базиса имеют
+порядок `[a, u, w]`, где `u = normalize(v × a)`, `w = a × u`. Плоскость
+прямоугольника — XY, поэтому первая сторона идёт вдоль авторской оси, а
+вторая поворачивается к камере. Это сохраняет соединение между точками
+локатора при движении камеры. Если поставить ось в третий столбец,
+прямоугольник займёт поперечную плоскость и связь превратится в полоску или
+небольшой диск.
+
+Порядок подтверждён аргументами конструктора матрицы в GOG `Effect.dll`
+(сборка из [таблицы бинарников](original-binaries.md), SHA-256
+`386a3e8db1ce23f61957825d633c7dae1ed40c25cab0cf10a534385fd7cee789`):
+RVA `0x93D0`, ветка mode 1 с `0x9739`, сборка столбцов `0x97F6–0x983C`.
+Например, `a = (1, 0, 0)` и `v = (2, 1, 0)` дают столбцы
+`(1, 0, 0)`, `(0, 0, -1)`, `(0, 1, 0)`. При нативном вызове длины последних
+двух столбцов могут немного отличаться от единицы из-за приближённой
+нормализации выбранного `g_FastProc`; порядок осей от этого не меняется.
+
+У сооружения `s_tree_58` в миссии «Конструктор» FXID `tree_ray_58` задаёт два
+слоя жёлтого луча: команды Op3 mode 1 с ресурсами `defl_y_add` и `defl_y1_add`.
+Их геометрию сверили с CPU-вызовом GOG `Effect.dll` `0x93D0`. На семи временных
+фазах и трёх направлениях камеры для каждой команды (42 выборки) вершины
+совпали с Rust evaluator с максимальной покомпонентной разницей `0.0001373291`.
+Оракул получает уже выбранные Rust позицию, ось и масштаб, а world-camera
+переводится в `cameraLocal` тем же component-wise projection, что и native
+`FastProc + 0x68`. Поэтому это подтверждает только геометрию Type 1 helper-а на
+этих входах: обновление и расписание эффекта, UV/material и пиксельное
+совпадение здесь не проверялись.
## Частицы Op7
@@ -314,27 +375,66 @@ Evaluator типа 2 принимает индекс context явно и учиÑ
семена отдельных экземпляров лишь детерминированно заменяют process-global
состояние и не воспроизводят общий порядок вызовов.
+`f_gener_ball` evaluator покрывает Op4 mode 0 с положительным знаменателем в
+`body+0xC4` и Op9 mode 2. Для Op9 selectors `body+0xC4` со значениями 0, 1 и 2
+отображаются в native flag bases 3, 5 и 6. Нулевой `body+0xC8` проецирует UV на
+XY, ненулевой выбирает статические quad/cap координаты. Op4 вычисляет локальное
+расстояние камеры тем же component-wise projection, что использует native path.
+
+FX-команды используют общий preview clock и ограниченный набор time modes:
+mode 0 оставляет фазу равной нулю, mode 1 вычисляет `elapsed / duration` без
+зажатия, mode 2 повторяет цикл. Header flags `0x200` и `0x20` преобразуют эту
+фазу; command path `0x8120` зажимает результат только после преобразований.
+Effect renderer проверяет header bit `0x800` только при
+render selector 0; native AniMesh component path был захвачен с selector 1,
+поэтому preview использует selector 1 и не подавляет такие visual commands.
+Этот capture также показал `0xD00` FX entries в 53-entry manager. Динамическое
+состояние manager `A8`, связанное с header gate и фактической FX phase, пока не
+моделируется. Header bit `0x10` относится к activation lifecycle и сам по себе
+не отключает draw.
+Header jitter и random shift хранятся один раз на Control row и совместно
+применяются к его sibling-командам; отдельный детерминированный поток каждой
+строки сохраняет эту связь, но не повторяет порядок глобального native RNG.
+Неразобранные группы, event predicates и неразрешённые owner attachments
+пропускаются.
+
+Для visual FX material берётся из resource reference команды. MAT0 выбирает
+фазу по normalized FX phase, независимо от wall-clock анимации world materials.
+Command opacity передаётся vertex alpha, а FX MAT0 alpha не умножает её второй
+раз; texture alpha продолжает участвовать в shader. Текущая preview-ветка
+использует SourceAlpha и read-only depth state и допускает только MAT0-фазы,
+проходящие локальный predicate additive-RGB approximation. Семантика native
+`flag40` и directional-light ветки остаётся открытой.
+
+Generated Op9 mesh разделяется на отдельный draw range для каждого native
+quad и cap. Каждый range получает ключ по камере до первой индексированной
+вершины, в порядке убывания расстояния. Равные ключи используют midpoint
+insertion из native queue, поэтому их порядок не является стабильным по
+первоначальному времени поступления. Preview использует category 0 как
+highest-detail mesh; projected-size LOD и полное визуальное совпадение размера
+и формы пока не реализованы.
+
## Частицы Op8
`fparkan-fx::smoke` разбирает body Op8, хранит кольцевой пул команды и выдаёт
quad-ы с ориентацией к камере для активных слотов. Нативная нормализованная
-фаза окна ограничивается диапазоном 0–1 и задаёт интервал проверки следующего события;
-сравнение строгое (`schedule_base + interval < update_clock`). Все события
-catch-up в одном обновлении проверяются по тому же нормализованному интервалу;
-после события нативный код продвигает базу расписания интервалом, вычисленным
-по сырой фазе эффекта, а возраст слота считает по нормализованному интервалу.
-Когда фаза равна единственной границе окна, деление `0/0` даёт NaN, который
-нативный unordered-путь сравнения ограничивает нулём.
+фаза окна ограничивается диапазоном 0–1 и задаёт интервал проверки следующего
+события; сравнение строгое (`schedule_base + interval < update_clock`). Все
+события catch-up в одном обновлении проверяются по тому же нормализованному
+интервалу; после события нативный код продвигает базу расписания интервалом,
+вычисленным по сырой фазе эффекта, а возраст слота считает по нормализованному
+интервалу. Когда фаза равна единственной границе окна, деление `0/0` даёт NaN,
+который нативный unordered-путь сравнения ограничивает нулём.
Нулевая база расписания служит признаком начального состояния: обновление
сохраняет текущую опору эмиттера и базу времени, не создавая частицу. Если
первое время равно нулю, следующее обновление повторно захватывает опору. При
событии скорость опоры считается от сохранённой позиции до текущей за время
от базы расписания; слот смещает сохранённую опору на `скорость * due_interval`.
-Обновление без событий не переносит сохранённую опору к текущему владельцу.
-Откат часов корректирует только базу расписания до текущего времени: слоты,
-опора и три потока случайных
-чисел не сбрасываются.
+При нулевой скорости оригинал подставляет ось X владельца и для направления,
+и для этого смещения. Обновление без событий не переносит сохранённую опору к
+текущему владельцу. Откат часов корректирует только базу расписания до текущего
+времени: слоты, опора и три потока случайных чисел не сбрасываются.
Evaluator принимает отдельные начальные состояния для эмиттера, знаковых
отклонений и материала. Переход каждого потока совпадает с общим native LFSR,
diff --git a/docs/reference/msh.md b/docs/reference/msh.md
index a28514d..130a203 100644
--- a/docs/reference/msh.md
+++ b/docs/reference/msh.md
@@ -18,6 +18,7 @@ type 9 service stream
type 10 strings and node names
type 13 Batch20 records
type 15 auxiliary stream
+type 16 optional vertex-colour data
type 17 auxiliary data
type 18 packed UV1 (optional secondary coordinates)
type 19 animation frame map
@@ -25,6 +26,9 @@ type 20 rare auxiliary table
```
Reader ищет entries по type, но сохраняет исходный порядок для roundtrip.
+Поле `ModelAsset.type16_present` отмечает только наличие entry типа 16. Данные
+payload сохраняются, но этот путь рендера пока не преобразует их в цвета
+вершин.
## Node and slot selection
@@ -91,6 +95,51 @@ translation суммируется, а rotations умножаются; послÐ
запускает игровой Control/AI и не обещает полного совпадения с runtime или
всеми x87 rounding modes.
+## Control clock и pose компонентов
+
+Статический Vulkan preview начинает с позы, вычисленной из сохранённых `.ctl`
+bindings. Пока не задан `--static-animation-frame`, при каждом новом сэмпле
+сцены приложение сэмплирует поддержанные tag 21 записи для каждого экземпляра
+компонента и заново строит его MSH node hierarchy; time и transition используют
+общий runtime clock. Динамический sampler принимает tag 21 mode `0`,
+controller flags `5` (raw value `0xFFFFFFFF` трактуется как `5`),
+разрешённый gate provider и static row mapping: оба
+variable index равны `0xFFFFFFFF`, previous-row flag не установлен, transition
+rate конечен и положителен.
+
+Fallback к статической CTLD-позе всего экземпляра компонента срабатывает только
+при следующих ошибках sampler: `UnsupportedTag21Mode`,
+`UnsupportedVariableMapping`, `MissingGateProvider`,
+`GateProviderKeyOutOfRange`, `MissingGateProviderValue` и
+`GateProviderCountMismatch`. Он заменяет весь динамический результат экземпляра,
+а не одну запись. Остальные sampling errors завершают обновление сцены; например,
+`UnsupportedPreviousRowMapping` и `InvalidTransitionRate` не включают fallback.
+
+`--control-clock-ms <u32>` воспроизводит общий MSH/FX clock линейно: первый из
+`--frames` успешно представленных кадров получает `0`, последний — заданное
+время. Replay clock привязан к числу представленных кадров: повторная подготовка
+того же кадра, включая swapchain recreation, использует прежний sample.
+`--static-animation-frame <u16>` отключает динамическую MSH выборку и задаёт один
+frame всем компонентам. Этот override не включает AI и не превращает preview
+в полный игровой Control runtime.
+
+## Control FX и owner transform
+
+Для выбранной Control effect group runtime берёт строки без predicate, chain и
+state gate. Type 4 row offsets выбирают три точки из CTL point list; позиции
+точек задают локальное начало, а их направления — локальную basis. К basis
+применяются FX header scale и локальные random shifts. Затем матрица проходит
+через mission rotation/translation и object scale, optional mounted-component
+pose, owner-node pose и локальную матрицу эффекта. Поэтому FX mounted-компонента
+следует её текущему MSH owner pose, а не остаётся привязанным к mission root.
+
+Когда point record задаёт допустимый owner node, его pose разрешается из MSH
+hierarchy. Для mounted external components node pose вычисляется относительно
+component root и применяется вместе с уже рассчитанным mount pose. Internal
+`INTO` child controller/equipment links пока не интерпретируются как MSH socket;
+если owner pose нельзя разрешить, его FX пропускается. Нативное сочетание child
+component scale, equipment attachment и CPT basis остаётся неустановленным.
+
## Vertex streams
Основные vertex streams имеют фиксированный source index. У type 3 `attr1`
@@ -149,11 +198,114 @@ struct Batch20 {
```
Byte `material_index` выбирает строку WEAR; high word at `+0x02` сохраняется
-отдельно для native overrides. Loader проверяет, что
+отдельно для native overrides. Слово `local_batch_index` остаётся raw-полем
+`+0x06`: в пути CBuilding helper retry оно вместе с базой модели задаёт
+связанный корень Node38, а у отдельной `ModelAsset` база равна нулю. Вне этого
+пути слово сохраняется без вывода о его значении. Loader проверяет, что
`index_start + index_count` остаётся внутри type 6, а каждый использованный
индекс через `base_vertex` попадает в position stream. `vertex_count` и
`local_batch_index` доступны renderer для исходного draw contract.
+## Type 7 и сегментный запрос
+
+Type 7 необязателен для обычного MSH preview. Если stream есть, каждые 16 bytes
+описывают одну грань: `tri_flags` находится по `+0`, четыре неинтерпретируемых
+слова — по `+2`, `+4`, `+6` и `+14`, а signed `normal[3]` — по `+8`, `+0xA` и
+`+0xC`. `ModelAsset.triangle_descriptors` сохраняет декодированную таблицу как
+`Option`; отсутствие Type 7 не делает статическую модель недействительной.
+
+`query_slot_segment` использует Node38 header mask, Batch20 leading DWORD
+`batch_flags | (material_index_hi << 16)` и `tri_flags`. Для каждого поля
+include mask требует все установленные биты, exclude mask пропускает запись при
+любом совпавшем бите. Descriptor cursor начинается с `Slot68.tri_start` и
+сдвигается на `Batch20.index_count / 3` после каждой записи, даже если batch
+или грань отфильтрованы. Индексы грани читаются из type 6 с учётом
+`base_vertex`.
+
+Правило восстановлено для GOG-бинарей `AniMesh.dll`
+(`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`) и
+`Ngi32.dll` (`bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5`).
+Callback `AniMesh.dll+0x11090` переводит signed type-7 normal множителем
+`1 / 32767`; функция `AniMesh.dll+0x11EB0` записывает этот scale в глобальное
+поле. Callback вызывает FastProc slot `+0xB4`: в этой сборке он указывает на
+`Ngi32.dll+0x1D560`. После обычной загрузки DLL порог `Ngi32.dll+0x37224`
+равен нулю, поэтому plane test принимает только переход от положительного
+расстояния к неположительному (`dot(normal, end - start) < 0`, расстояние в
+начале `> 0`, в конце `<= 0`). `Batch20.batch_flags & 0x0002` разрешает повторный
+plane test в обратном направлении, только если прямой plane test не пересёк
+плоскость. Пересечение проверяется export ordinal 202
+(`Ngi32.dll+0x1E60`, `mrnPointInPoly`): проекция выбирает ось с наибольшим по
+модулю normal component, а точка проходит грань, пока ни одно edge expression
+не меньше нуля. Равенство нулю принимается, поэтому попадание на ребро или
+вершину считается попаданием. Поздний кандидат с тем же squared distance
+заменяет предыдущий; расстояние считается от исходного первого endpoint.
+
+Native helper получает точку пересечения через `RCPSS` и один шаг уточнения;
+Rust использует обычное деление `f32`, поэтому последние биты координат могут
+отличаться. Игнорируемый тест с ресурсами проверяет исходную стену, дверной
+проём и обратное направление на `fortif.rlb/fr_l_plant.msh`.
+
+### Мировой отрезок по выбранным узлам
+
+Локальный запрос выше проверяет один узел и slot модели. Runtime-функция
+`query_building_segment_world` принимает мировой отрезок и исходный список
+выбранных узлов со slot-ами и их преобразованиями `world_from_node`. Первый
+проход сохраняет порядок этого списка, переводит концы отрезка обратной
+матрицей и выбирает ближайшее попадание в мире.
+
+```text
+segment_node = inverse(world_from_node) * segment_world
+hit_world = world_from_node * hit_node
+distance² = dot(hit_world - original_start, hit_world - original_start)
+```
+
+`original_start` остаётся началом первоначального мирового отрезка, даже когда
+запрос перезапускается. Если ближайший batch имеет bit `0x0008`, его ключ
+`local_batch_index` выбирает новый корень Node38, а отрезок начинается в
+мировой точке helper-попадания и идёт к исходному концу. Runtime обходит
+метаданные этого поддерева даже у узлов без переданной геометрии; сами slot-
+запросы выполняются только для узлов, присутствующих во входном списке. Обход
+проверяет сначала допустимых потомков, затем сам узел. Header bit `0x0010`
+прекращает обход его потомков; bit `0x0004` у дочернего узла исключает всю эту ветвь, но bit
+`0x0004` у текущего корня не мешает запросу его собственной геометрии. Слово
+родителя `0xFFFF` не связывает узел с дочерним корнем, а helper-ключ `0xFFFF`
+считается ошибкой данных.
+
+В каждом проходе batch `0x0200` и type-7 triangle flags `0x0024` исключаются.
+Ближайшее попадание и разрешение равных расстояний сохраняют правила локального
+запроса; расстояние измеряется от первоначального начала. Счётчик ограничивает
+все пересечения: при посещении 200 запрос завершается ошибкой до проверки bit
+`0x0008`.
+
+Эта схема сверена с GOG `Terrain.dll` SHA-256
+`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` (RVA
+`0x35430`, `0x35961`) и `AniMesh.dll` SHA-256
+`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e` (RVA
+`0x134D0`, `0x10A50`).
+
+Игнорируемый тест загружает настоящий объект PLANT 4 миссии
+AutoDemo и использует его исходную CTLD-привязку Node3: `frame_a = 0`,
+`frame_b = 1`, начальное смешивание `0`. В контроллере корневого PLANT нет
+записей tag-21. В
+закрытой начальной позе запрос, вызванный через обновление камеры приложения,
+останавливает движение. Затем тест вручную задаёт время `1` только для
+Node3, пересчитывает pose и преобразование присоединения и повторяет тот же
+путь обновления камеры; отрезок проходит через проём. Это проверяет подачу
+двух заданных в MSH поз в коллизию приложения, а не открытие двери событием
+миссии и не её видимое обновление в игровой сцене.
+
+Интерактивный preview создаёт отдельную запись для каждого отрисовываемого
+корневого строения класса `FORT` и его отрисовываемых компонентов. На каждом
+движении камера получает текущую Node38-иерархию компонента и его актуальное
+преобразование присоединения, переводит выбранные узлы и slot-ы в мировой
+запрос и выполняет его после ограничения цели рельефом. Попадание сохраняет
+прежнюю позицию камеры, но не отменяет новый поворот; повтор ожидающего кадра
+не применяет шаг движения второй раз. Так приложение связывает мировой
+MSH-запрос с движением свободной preview-камеры. Выбор первого корня и полный
+состав мирового списка ещё не сверены с оригиналом; игровые источники поз и
+событий, а также поведение стратегической и следящей камеры остаются
+открытыми.
+
## Сторона грани в цветовом проходе
В цветном проходе `Terrain.dll`, функция `0x100296F0`, сравнивает камеру с
diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md
index 3085731..08d7c37 100644
--- a/docs/reference/render-frame.md
+++ b/docs/reference/render-frame.md
@@ -24,22 +24,52 @@
затенения, вызывается завершение рендерера, снимается `in_render`, затем
рассылается `end-of-render`.
-При retry кадра после пересоздания swapchain сохраняется прежний pending
-sample, включая уже вычисленные позы и состояния FX и материалов. Для нового
-drawable обновляются aspect, проекция и геометрия, зависящая от viewport; pose
-берётся из pending camera. Новые события управления камерой остаются в
-input controller и применяются следующим sample. Экранные billboards погоды
-повторно проецируются из закэшированных мировых головы и хвоста, без второго
-шага симуляции окружения и повторной отправки звуковых событий. Пауза нулевого
-drawable не добавляет время.
-
-Pending frame хранит пакет MAT0 для мира и, при наличии GPU-окружения, отдельный
-пакет неба. Пакеты содержат выбранные селектор и категорию конвейера, фазовые
-коэффициенты материала, четыре UV-преобразования и кортеж фаз дополнительных
-текстур. Повторное применение пакета при retry или нескольких resize не выбирает
-фазу заново и не продвигает RNG. Перед отрисовкой сначала применяются материалы
-мира и их порядок прозрачности, затем MAT0 неба, после чего viewport-зависимый
-проход неба записывает свои переопределения.
+В интерактивном preview часы материалов и окружения прибавляют полное время
+между обновлениями. Ограничение шага в 100 мс остаётся у движения свободной
+камеры и не обрезает эти часы. В режиме replay окружение получает разницу
+последних применённых значений общего control clock.
+
+Если swapchain просит повторить ещё не представленный кадр, preview сохраняет
+его общий clock, уже вычисленные позы, FX/RNG и фазы материалов. При изменении
+drawable обновляются камера, aspect и viewport, после чего sky, environment
+geometry и projected shadows пересобираются для нового размера.
+Положение и ориентация камеры берутся из сохранённого состояния
+ожидающего кадра; новый ввод мыши применяется при следующем сэмпле. Сэмпл
+погоды остаётся прежним: экранные billboards дождя и снега повторно
+проецируются из закэшированных мировых головы и хвоста, без второго шага
+симуляции окружения или повторной отправки звуковых событий. Следующий такт
+выбирается после успешного представления кадра; пауза нулевого drawable не
+добавляет время.
+
+## Прозрачность порталов MSH
+
+GOG `Terrain.dll` (SHA-256
+`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`),
+RVA `0x1B0F0`, заменяет diffuse alpha пакета без type 16 с batch bit `0x0008`.
+При `0x0010` аргумент кривой — `sqrt(distance)`, границы — `2.6 / fov` и
+`7.7999997 / fov`, наклон — `fov / (7.7999997 - 2.6)`. Без этого бита
+`0x0040` задаёт ноль; иначе границы — `75 * 1.3 / fov`, `95 * 1.3 / fov`,
+наклон — `fov / (95 * 1.3 - 75 * 1.3)`. До ближней границы alpha равна нулю,
+после дальней — единице. Внутри перехода alpha равна произведению смещения от
+ближней границы на наклон. Это замена alpha MAT0, а не дополнительный множитель.
+
+Метод расстояния `0x246A0` через `0x247B0` читает первую индексированную
+вершину пакета, преобразует её в мир и вычисляет квадрат расстояния до камеры.
+Текущий Ngi32 dispatch `FastProc+0x2C` указывает на RVA `0x1D6D0`:
+`RSQRTSS` возвращает приближение обратного корня, которое умножается на квадрат
+расстояния. Получается приближённое евклидово расстояние; дополнительный `FSQRT`
+ветви `0x0010` выполняется уже над ним. Хеш Ngi32 —
+`bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5`.
+Скаляр камеры в шестом float getter `0x4F5F0` и поле CShade `+0xBB4`
+проверены в работающем оригинале: оба содержат текущий горизонтальный FOV.
+
+`legacy_msh_portal_alpha` хранит кривую в `fparkan-render`. Приложение пока
+берёт первую индексированную вершину своего диапазона и обычный `sqrt`,
+поэтому выбор вершины после native CPU clipping и точность `RSQRTSS` не
+воспроизводятся. Счётчик `render_coverage portal_fade_packet_selection_approx`
+показывает число таких диапазонов. Поток type 16 сохраняет прежний отдельный
+путь и ещё не декодирован. Это ограничивает приёмку на частично отсечённых
+порталах и не позволяет считать интерьер принятой эталонной сценой.
## Элемент отрисовки
@@ -68,6 +98,23 @@ Pending frame хранит пакет MAT0 для мира и, при налич
`batch` `0x108`. Такой диапазон при включённой проверке глубины не записывает
глубину. За фиксированными диапазонами окружения сохраняются их явные состояния.
+Для MSH без потока цветов type 16 диапазон, цвет которого вычисляет CPU, при
+raw-флаге `Batch20` `0x0008` получает переопределение alpha только в одном
+подтверждённом случае. В GOG `Terrain.dll` с SHA-256
+`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` проверка
+по RVA `0x1B0F0` задаёт alpha `0`, когда установлен bit `0x0040` и сброшен
+`0x0010`; поэтому для `0x0148` alpha равна нулю. Для `0x0108` и `0x0118`
+превью сохраняет alpha MAT0; для `0x0058` действует приоритет bit `0x0010`,
+также оставляя alpha MAT0. При наличии type 16 или отсутствии `0x0008`
+переопределения нет. Оно относится к одному draw range и не меняет общий
+материал; alpha `0` временно задаёт число индексов `0`, а при очистке
+переопределения исходное число индексов восстанавливается.
+
+Общий fade по расстоянию пока не реализован. Открыты точная метрика расстояния,
+выбор первого CPU-принятого треугольника диапазона и эквивалентность frustum- и
+culling-отбору оригинала. Поэтому состояния `0x0108` и `0x0118` оставляют alpha
+MAT0, пока эти входные данные не установлены.
+
В превью с фиксированной камерой диапазоны туманности и звёзд, купола и облаков
явно помечаются как прозрачный фон. Непрозрачные диапазоны мира остаются на
своих местах; прозрачная очередь мира сортируется от дальних элементов к
@@ -100,6 +147,10 @@ RVA `0x13D90`. Эти данные подтверждают только порÑ
путь превью принимает входные данные с матрицей `selector0_words`, полученные через
[процедуру захвата нативного кадра](../../tools/native-frame-capture/README.md).
+Формат захвата сохраняет локальную матрицу `selector0_words` и, когда она
+доступна, записывает `effective_world_matrix_words` с маркером пространства
+`world`.
+
Процедура захвата записывает наблюдавшийся путь миссии и значение
`atmosphere_seconds`, если проверка потока, поколения и часов прошла успешно.
В превью с фиксированной камерой эта абсолютная фаза расписания используется,
@@ -146,14 +197,26 @@ RVA `0x4EBE0` использует соглашение о вызовах `__std
события, слушателю задаются захваченные позиция, направление вперёд и верхняя
ось; начальная сортировка диапазонов мира использует ту же позицию.
+Если в JSON присутствует пара `effective_world_matrix_space: "world"` и
+`effective_world_matrix_words`, превью использует мировую матрицу. Матрица
+`selector0_words` остаётся локальной и используется как запасной вариант только
+при отсутствии мировых полей, если `parented_camera` не равно `true`. Для
+родительской камеры без эффективной мировой матрицы вход отклоняется.
+
Захват может содержать наблюдавшийся путь миссии и проверенное значение
`atmosphere_seconds`; последнее используется только при отсутствии явного
параметра командной строки. Это значение фиксирует фазу расписания атмосферы
-при сравнении с неподвижной камерой, тогда как FX продолжает получать реальное
+при сравнении с неподвижной камерой, тогда как FX по умолчанию получает реальное
время кадра. `simulation_time_seconds`, погода, RNG и полное состояние миссии
остаются неизвестными, поэтому совпадение камеры и фазы не подтверждает
визуального совпадения с нативным кадром.
+Параметр `--control-clock-ms` воспроизводит единые часы MSH/FX от нуля до
+выбранного значения в миллисекундах за указанное число кадров. Без этого
+параметра часы идут в реальном времени. Для `--readback-sequence-out` этот
+управляемый режим обязателен. Он не восстанавливает
+`simulation_time_seconds`, погоду, RNG или полное состояние миссии.
+
Для неба превью вычисляет направление как `atan2(forward.x, forward.y)`. Оно
совпадает со вторым выходным значением, вычисляемым в `Terrain.dll` по RVA
`0x4F9C0`; при выборе сектора экранного градиента `CSky` добавляет π по RVA
@@ -166,21 +229,50 @@ RVA `0x4EBE0` использует соглашение о вызовах `__std
`1.3` рад — параметры, наблюдавшиеся в проверенном контексте GOG Ngi32
([формула native projection](../tomes/05-render.md#камера-и-размещение-в-оригинале)).
Для отношения ширины к высоте `a` превью вычисляет вертикальный угол
-```rust
-fn vertical_fov(self, aspect: f32) -> f32 {
- 2.0 * ((self.horizontal_fov * 0.5).tan() / aspect).atan()
-}
-```
-
-Код заменяет невалидный `aspect` на отношение 16:9; горизонтальный угол
-остаётся `1.3` рад после изменения размера окна, а новый viewport и матрица
-камеры применяются при следующей отрисовке.
+`2 * atan(tan(horizontal_fov / 2) / a)`. Поэтому горизонтальный угол остаётся
+`1.3` рад после изменения размера окна; новый viewport и матрица камеры
+применяются при следующей отрисовке.
Проекция направления не сравнивает косинус угла с ближней дистанцией: она
отбрасывает только лучи за камерой и почти параллельные плоскости изображения
(`forward · direction <= 1e-4`). Лучи в передней полусфере могут проецироваться
за край viewport; владелец спрайта отдельно решает, нужно ли его рисовать.
+После движения свободная камера ограничивает X и Y прямоугольником
+поверхностей карты, затем поднимается над найденным рельефом на заданный
+зазор. Границы берутся из корня пространственного индекса `TerrainWorld`,
+поэтому неиспользуемые вершины файла их не расширяют и повторного обхода
+геометрии в каждом кадре нет:
+
+```rust
+position[axis] = position[axis].clamp(min[axis], max[axis]); // axis = 0, 1
+position[2] = position[2].max(ground_height + ground_clearance);
+```
+
+Для каждого отрисовываемого корневого объекта класса `FORT` и каждого его
+отрисовываемого компонента интерактивный путь создаёт отдельную запись проверки.
+Она использует те же текущие позы узлов и преобразования присоединённых
+компонентов, что и геометрия кадра. После ограничения целевой позиции по
+TerrainWorld камера проверяет непрерывный отрезок от прежней позиции до цели.
+Если после нативных MSH-фильтров остаётся пересечение, позиция возвращается к
+началу шага, а рассчитанный из ввода новый поворот сохраняется. Маски batch и
+type-7, а также продолжение отрезка по helper-link описаны в
+[разделе MSH о сегментном запросе](msh.md#type-7-и-сегментный-запрос).
+
+Повторная отправка того же ожидающего кадра, например после изменения размера
+области отображения, использует уже подготовленное состояние и не применяет
+движение второй раз. `--legacy-camera-capture` сохраняет положение захваченной
+камеры без изменений и не запускает проверку зданий. Это диагностическое
+ограничение, а не восстановленное правило игровой или следящей камеры
+оригинала. В проверке начальная поза берётся из декодированных связей CTLD;
+проход двери отдельно проверяется после явной выборки записанного в MSH кадра 1
+только для Node3. Так проверяется вход запроса столкновения и путь камеры;
+видимая дверь не анимируется, а миссионные команды не воспроизводятся.
+
+Высота полёта сверху не ограничена. Это ограничение диагностической камеры
+превью; точные правила игровых и следящих камер оригинала остаются
+неподтверждёнными.
+
## Ограничения точного совпадения
`VulkanStaticCamera::from_legacy_d3d7` выполняет восстановление вида и проекции
@@ -202,6 +294,11 @@ D3D7 в `fparkan-render`, а затем один раз меняет знак в
- переход на запасной набор возможностей устройства;
- квантование времени симуляции.
-Для отладки требуется детерминированный захват кадра: состояние камеры,
-идентификаторы видимых объектов, список элементов отрисовки, ключи конвейера,
-матрицы и хэши промежуточных буферов.
+Параметр `--readback-sequence-out` сохраняет необработанные байты каждого
+отправленного кадра; в имя файла записываются индекс кадра, значение общих
+управляемых часов и размер изображения. Байты соответствуют выбранному формату
+изображения swapchain Vulkan и не гарантируют порядок каналов R, G, B, A. Числовое
+формат выводится как `readback_format`; расширение `.rgba` само по себе порядок
+каналов не определяет. Последовательность не содержит структурированных ID
+видимых объектов, списка элементов отрисовки, ключей конвейера, матриц или
+хэшей промежуточных буферов.
diff --git a/docs/tomes/03-resources.md b/docs/tomes/03-resources.md
index a349a79..f092ece 100644
--- a/docs/tomes/03-resources.md
+++ b/docs/tomes/03-resources.md
@@ -60,6 +60,12 @@ archive object и увеличивает счётчик владельцев. Г
предметного объекта не должен напрямую закрывать архив: он зависит от данных,
но не владеет файлом как ресурсом операционной системы.
+План ресурсов считает запросы от уникальных визуальных прототипов. Если два
+слота материала ссылаются на один MAT0 с одной и той же текстурной фазой, это
+два запроса текстуры, хотя подготовленный набор хранит один ресурс. Повторные
+ссылки на `lightmap` учитываются так же. `AssetPreparationReport` и ограничения
+подготовки считают уникальные подготовленные ресурсы.
+
## Имена и пути
Большинство игровых имён сравнивается без учёта регистра в ASCII-диапазоне. Это
@@ -471,6 +477,15 @@ struct UnitDatRecord112 {
Требование обязательного terminator применяется только к полям, где оно доказано
форматом.
+У AI есть отдельный потребитель Unit DAT: исследовательский chooser читает
+заголовок с magic `F0F1`, сохраняет второй DWORD как непрозрачный и завершает
+разбор на границе первого полного корневого дерева. `immediate_child_count`
+закрывает это дерево по preorder; байты после него, включая неполную запись или
+orphan-record, chooser уже не читает. Поэтому Rust для этого пути находит
+bounded 112-byte prefix и передаёт его существующим `decode_unit_dat` и
+`unit_component_tree`. Общий декодер остаётся строгим к размеру всего файла
+`8 + 112 * N`; частный framing AI не меняет этот контракт.
+
Проверено 425 файлов / 5 219 records Части 1 и 676 файлов / 8 145 records
Части 2. Все соответствуют формуле размера, `kind == 1` и
`archive_name == "objects.rlb"`.
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index 84de6ee..295e718 100644
--- a/docs/tomes/05-render.md
+++ b/docs/tomes/05-render.md
@@ -362,30 +362,14 @@ local.set_translation(pose.position);
world[n] = world[parent(n)] * local;
```
-Статический Vulkan preview берёт CTLD `.ctl` из явной ссылки прототипа.
-Поддерживаемые Control timeline с tag 21 сэмплируются при запуске на native
-clock `0`; разрешённые NDP providers передают конечные type-1 значения.
-Неподдерживаемый режим или variable mapping, а также отсутствующее или
-неразрешённое gate value оставляют компонент на статических CTLD bindings. В
-timeline участвуют только включённые строки; при повторном node ID поздняя
-строка заменяет раннюю.
-
-Без `--static-animation-frame` preview сохраняет экземпляр Control и при
-каждом redraw сэмплирует поддерживаемый timeline на текущем native clock.
-Обновлённая Node38-поза перепроецирует видимую геометрию в заранее выделенный
-vertex span, оставляя индексы и draw ranges неизменными. Если в пути Unit DAT
-компонента есть такой Control, заново вычисляется его EXTO mount socket и
-перепроецируется дочерняя геометрия; дочерний mount использует preorder-порядок
-Unit DAT. Внутренние INTO-компоненты здесь не считаются глобальными MSH
-socket mounts. Обновлённые вершины и shadow-треугольники попадают в общий
-mesh upload перед следующим кадром. Culling sphere восстанавливается от
-начальной native sphere и расширяется до текущих shadow-треугольников; это
-`shortcut:`, потому что native обновление bounding sphere для анимированной
-геометрии пока не установлено. Это путь сэмплирования Control-позы, не AI или
-анимация эффектов.
-
-Явный `--static-animation-frame N` переопределяет timeline и задаёт `N` всем
-узлам, пропуская runtime Control-сэмпл.
+Статический Vulkan preview сначала строит начальную позу по CTLD `.ctl`.
+В ней участвуют только включённые строки; при повторе node ID поздняя строка
+заменяет раннюю, а узлы без настройки остаются на кадре `0`. Без
+`--static-animation-frame` preview сэмплирует поддержанные Control timelines
+при каждом новом сэмпле сцены. Флаг `--static-animation-frame N` задаёт `N`
+всем узлам и пропускает runtime Control sample. Это визуальный preview позы,
+а не полный Control/AI runtime.
+
MSH sampler выбирает type 19 frame округлением `(time - 0.5)` к ближайшему
целому с ties-to-even, затем интерполирует выбранный type 8 key и следующий
key в исходном `time`; это portable-вариант native x87 conversion в default
@@ -515,6 +499,31 @@ Material manager кэширует разобранный MAT0 и texture handles
лучше вычислять на экземпляр материала, если random offset или локальное время
различаются между объектами; immutable phase data остаются общими.
+### Почему проём становится прозрачным вблизи
+
+Портал здания — поверхность, которая закрывает интерьер издалека и постепенно
+исчезает при приближении камеры. Если оставить ей постоянную непрозрачность,
+вместо окна или перехода получится тёмный прямоугольник. Прозрачность материала
+здесь заменяется значением, вычисленным для всего пакета треугольников; alpha
+текстуры продолжает участвовать в смешивании.
+
+Для MSH без потока цветов type 16 правило включает флаг batch `0x0008`.
+В обычной ветви при горизонтальном FOV `1.3` поверхность прозрачна до расстояния
+75 и непрозрачна после 95; между ними alpha растёт линейно. При другом FOV обе
+границы умножаются на `1.3 / FOV`. Расстояние берётся до первой вершины первого
+принятого треугольника, а не до центра здания.
+
+Флаг `0x0010` выбирает другую кривую: сравнивается квадратный корень уже
+вычисленного расстояния, границы равны `2.6 / FOV` и `7.7999997 / FOV`.
+Например, при FOV `1.04` поверхность на расстоянии 25 имеет alpha примерно
+`0.5`, а на расстоянии 5 полностью исчезает. Флаг `0x0040` без `0x0010`
+сразу задаёт нулевую alpha; при наличии обоих побеждает `0x0010`.
+
+FParkan пересчитывает эту alpha при каждом обновлении камеры, после анимации
+модели. Нулевое значение временно выключает диапазон индексов; при удалении
+камеры диапазон восстанавливается. Подробности оригинального вычисления и
+пока приближённого выбора вершины приведены в [описании кадра](../reference/render-frame.md#прозрачность-порталов-msh).
+
## Texm: текстуры, mip-уровни и атласы
`Texm` -- основной формат изображений. Он хранится в `Textures.lib`,
@@ -646,15 +655,15 @@ diffuse texture ломает LOD, atlas coordinates и динамическую
Vulkan preview строит проецируемые тени для объектов, отбрасывающих тени,
отбрасывает сферы вне шести clip-плоскостей с радиусом `sphere + 100` и
сохраняет порядок видимых кандидатов; frame builder обрабатывает не более 20
-объектов. Текущая реализация выделяет страницы в atlas `256 x 256` с 28 page
-slots и использует одно сглаживание. Preview применяет native defaults
-`RobShadowsOn = true` и `BldShadowsOn = false`: объект kind `4` проверяет
-первый флаг, остальные kinds — второй. Поэтому роботы BTLU остаются объектами,
-отбрасывающими тени, а объекты FORT и STAT их не создают. Preview пока не
-загружает `shade.cfg`, поэтому значения из пользовательского файла не меняют
-список объектов, отбрасывающих тени. Проверка действует только на проецируемые
-тени: отдельный список объектов для затенения солнца по-прежнему включает
-FORT, BTLU и STAT. Вызов preview передаёт `fade = 1.0` и LOD defaults:
+объектов, отбрасывающих тени. Текущая реализация выделяет страницы в atlas
+`256 x 256` с 28 page slots и использует одно сглаживание. Preview применяет
+native defaults `RobShadowsOn = true` и `BldShadowsOn = false`: объект kind `4`
+проверяет первый флаг, остальные kinds — второй. Поэтому роботы BTLU остаются
+объектами, отбрасывающими тени, а объекты FORT и STAT их не создают. Preview
+пока не загружает `shade.cfg`, поэтому значения из пользовательского файла не
+меняют список объектов, отбрасывающих тени. Проверка действует только на
+проецируемые тени: отдельный список объектов для затенения солнца по-прежнему
+включает FORT, BTLU и STAT. Вызов preview передаёт `fade = 1.0` и LOD defaults:
minimum LOD `1` и detail multiplier `1.0` отдельно для зданий и роботов.
Проекция учитывает вклад directional и point lights.
@@ -823,6 +832,39 @@ emit не должен повторно менять игровое состоя
типизировать header и resource references, а body редких commands сохранять raw
до подтверждения field-level semantics.
+В `fparkan-game` выбранные Control FX проходят отдельный transparent path.
+Начальная CTL group запускает только строки без predicates, chain или state
+gate. Type 4 point list задаёт локальную матрицу; текущие MSH owner и mounted
+node poses входят в transform до локальной FX basis. Реализованные visuals
+охватывают Op3 modes 0–2, Op4, Op7/8 particle pools и Op9 generated mesh. Op1
+selector 1 добавляет point lights без native flag `0x80000000` в общий diffuse
+список; остальные active records сохраняют flags для специальных путей и
+projected shadows. Effect renderer подавляет header bit `0x800` только при
+render selector 0. Native AniMesh component path захвачен с selector 1, а в
+его 53-entry manager присутствуют FX entries с header `0xD00`; preview
+использует selector 1 и пропускает такие visual commands. Изменения manager
+`A8`, которые задают runtime phase/header gate, пока не моделируются. Header
+bit `0x10` относится к activation lifecycle и не выключает draw.
+
+Для visual effect renderer использует MAT0, указанный самой командой. Его
+phase выбирается по normalized FX phase, а не по wall-clock clock материала
+мира. FX opacity остаётся в vertex alpha, MAT0 alpha для этой FX binding
+фиксируется на единице, texture alpha остаётся в shader. Preview передаёт FX
+через SourceAlpha/read-only-depth state. MAT0 phases проходят локальный
+eligibility predicate additive-RGB approximation; native `flag40` и
+directional-light branch остаются неразрешёнными.
+
+Op9 генерирует индексированную полусферу с отдельными vertex и UV данными и
+отправляет каждый quad и cap как отдельный draw range. Native packet distance
+callback берёт первую индексированную вершину (`Terrain.dll:0x246A0` через
+`0x247B0`), преобразует её согласно режиму packet и использует евклидово
+расстояние до камеры. Queue размещает большие расстояния раньше меньших;
+равный ключ вставляется в найденный binary-search midpoint, поэтому порядок
+равных packets не стабилен по времени поступления. Preview повторяет этот ключ
+и insertion order. Renderer category 0 остаётся highest-detail mesh; native
+category вычисляется из projected sphere size и quality table, поэтому
+фиксированная категория не является native LOD parity.
+
## Полный кадр
Крупный вход в world render проходит через `World3D::stdRenderGame`. Доказан
@@ -1035,42 +1077,48 @@ WEAR и MAT0 до объединения геометрии. Индексы GPU
animation phase; object batches сохраняют их legacy blend/depth/alpha state.
После загрузки world path создаёт интерактивную free-flight камеру (`WASD`,
-`E/Q`, `Shift`, `Ctrl`, RMB relative-look), обновляет listener и собирает
-environment frame для каждого подготовленного кадра. Event loop один раз
-сэмплирует камеру, общий animation clock, геометрию и окружение перед запросом
-redraw; redraw потребляет это состояние. Если surface требует пересоздать
-swapchain, тот же sample времени и состояние мира отправляются повторно.
-Перед повторной отправкой projection камеры и viewport-зависимые environment
-и shadow geometry обновляются для нового размера без продвижения сцены.
-Счётчик и replay clock продвигаются только после результата `Presented`.
-После шага движения камера проверяет высоту
-`TerrainWorld`: над конечной поверхностью сохраняется начальный просвет, а вне
-поверхности высота не ограничивается. Mission-local `sky.ske`/`sky.wea` выбирают
+`E/Q`, `Shift`, `Ctrl`, RMB relative-look), обновляет listener и на каждом
+кадре собирает environment frame. Для каждого отрисовываемого строения
+(корневого объекта класса `FORT`) и для каждого его отрисовываемого компонента
+создаётся отдельная запись проверки. Она использует те же текущие матрицы узлов
+и присоединённых компонентов, что и геометрия. После ограничения целевой
+позиции по TerrainWorld камера проверяет непрерывный отрезок от прежней позиции
+до цели; при пересечении
+она остаётся в прежней позиции, сохраняя новый поворот. Повторная отправка
+ожидающего кадра не применяет движение второй раз. Это ограничение
+диагностической свободной камеры, а не правило игрового или следящего режима
+оригинала.
+Mission-local `sky.ske`/`sky.wea` выбирают
атмосферное расписание и sky materials, `effects.rlb/env_lightning` даёт
lightning visual; weather particles, flares, sun/moon, point lights,
projected shadows и audio events обновляются вместе с камерой и временем.
`--atmosphere-seconds` задаёт старт времени, `--frames 0` оставляет цикл
-бесконечным, `--control-clock-ms N` воспроизводит общий clock анимации MSH и
-материалов вместе с окружением от нуля до `N` за заданное число кадров и требует
-как минимум два кадра.
-`--preview-roots` служит только диагностическим ограничителем.
+бесконечным, а `--preview-roots` служит только диагностическим ограничителем.
`--legacy-camera-capture` выбирает воспроизводимую камеру без free-flight
-управления: запись selector 0 задаёт D3D7 transform, clip параметры и native
-viewport RECT `[left, top, right, bottom]`. Путь сохраняет оси basis из view
-matrix вместе с roll, переносит полученный `clip_from_world` в preview camera,
-переводит RECT в физический drawable размер `(right - left) × (bottom - top)`
-и отклоняет несовпадение размера при
-создании или resize вместо растягивания кадра. Результат нужно сравнивать с
-тем же crop исходного PNG `[left, top, right, bottom]`. Контроллер
-`FreeFlightCamera` в этом режиме не создаётся. Подготовка кадра обновляет
+управления. `selector0_words` содержит локальную матрицу камеры. Если запись
+содержит `effective_world_matrix_words` с маркером
+`effective_world_matrix_space="world"`, preview использует эту мировую
+матрицу. При `parented_camera=true` отсутствие мировой матрицы — ошибка;
+локальный selector 0 остаётся legacy fallback только для записей без признака
+parented и без мировой матрицы.
+Если native helper явно применяет выбранную мировую камеру, setter работает в
+mode 2, а restore возвращает исходную локальную матрицу в mode 1. Clip
+параметры и native viewport RECT `[left, top, right, bottom]` идут из той же
+записи. Путь сохраняет оси basis из view matrix вместе с roll, переносит
+полученный `clip_from_world` в preview camera, переводит RECT в физический
+drawable размер `(right - left) × (bottom - top)` и отклоняет несовпадение
+размера при создании или resize вместо растягивания кадра. Результат нужно
+сравнивать с тем же crop исходного PNG `[left, top, right, bottom]`. Контроллер
+`FreeFlightCamera` в этом режиме не создаётся, но redraw продолжает обновлять
environment и projected shadows от captured camera. Перед environment update и
его audio events listener получает position, forward и up captured camera;
начальная сортировка world draw использует её же position. Phase capture может
содержать наблюдаемый mission path и проверенный `atmosphere_seconds`; viewer
использует эту абсолютную фазу по умолчанию, если не указан явный CLI override,
-и удерживает sample атмосферы на ней через readback frames. Без
-`--control-clock-ms` FX получает реальный frame time; с параметром environment
-и материалы используют replay clock. Это не полное simulation time: `weather`, RNG и
+и удерживает sample атмосферы на ней через readback frames. Фазы FX следуют
+выбранному control clock: без `--control-clock-ms` он обновляется по elapsed
+time, а с явным значением readback frames переигрывают заданный clock. Это не
+полное simulation time: `weather`, RNG и
остальное mission state остаются неизвестными, поэтому совпадение камеры и
фазы не доказывает visual parity с native frame.
diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md
index 48f4405..6edfb7a 100644
--- a/docs/tomes/06-behavior.md
+++ b/docs/tomes/06-behavior.md
@@ -168,8 +168,11 @@ handler лишь инициирует execution context (`+0x50 = 1`); его ga
младшие 32 бита, что даёт `0`. Значение `10001` также проходит дальше: оригинал
показывает предупреждение для чисел выше `10000`, но не отменяет создание AI.
-Rust исполняет только подтверждённую форму этого действия. Поддержанные записи
-применяются к состоянию лишь после успешного прохода всего первого `Init`.
+Rust исполняет подтверждённый поднабор первого `Init`: действие `Handler(19)`
+и формулы из одного атома либо двух атомов с бинарным `+` или `-`. Здесь атом —
+число или имя переменной. Поддержанные шаги сразу обновляют временную копию
+varset, поэтому следующая формула читает уже записанные значения. К состоянию
+клана записи попадают лишь после успешного прохода всего первого `Init`.
Если встретилась неподдержанная форма, preview сохраняет пакет, сообщает
`Unsupported` и оставляет значения по умолчанию; при полной загрузке та же форма
становится ошибкой. Это политика Rust для ограниченного исполнителя, а не правило
@@ -177,8 +180,62 @@ Rust исполняет только подтверждённую форму эÑ
событие считается исполненным без записей. Остальные события пока не образуют
исполняемую VM.
+В `Init` встречаются и отдельные присваивания результатов формул. Их запись
+выбирает строку формулы по порядковому номеру из соседнего `.fml`; этот файл
+относится к конкретной базе сценария, а строки после нужного номера сохраняют свои позиции.
+Для первой кампанийной миссии `Mission.01` (`tut1_pl2.fml`) строка 0 записывает
+нули в `df0..df3`; в миссии `Single.01` (`scr_pl_1.fml`) строка 0 записывает
+нули в `dT8` и `dT9`.
+Клан 1 той же миссии использует первые девять строк `scream.fml`: восемь
+проверенных значений попадают в `DWORD` с усечением дробной части, а `0.8`
+сохраняется как число `float`. Например, если `source` равен `10`, выражение
+`source + 2.0` записывает `12`; следующее `target - source` читает новое значение
+`target` и получает `2`. Каждая операция работает с `f32`: значение ячейки
+`DWORD` сначала читается как беззнаковое число и переводится в `f32`, а результат
+сохраняет тип целевой ячейки. Поддерживаются только один или два атома и один
+бинарный `+` либо `-`; произвольная грамматика формул и остальная VM не
+восстановлены. В отдельной точечной проверке вычисления и записи оригинальной
+DLL и Rust совпали для 13 выбранных формул. Эта сверка проверяет только
+эти значения и не запускает исходную миссию. Тест полной загрузки подтверждает,
+что `Mission.01` проходит этот `Init`; у `Single.01` следующий неподдержанный
+`Handler(43)` встречается в clan 1, записи 10. Никакое значение не подставляется
+вместо этого действия.
+
### Первый запуск сценария
+Перед настройкой первого шага каждый AI получает начальный массив DWORD. В
+`iron3d.dll` SHA-256
+`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`
+`FUN_100392e0` (RVA `0x392e0`) повторяет вызов слота AI `+0x38` столько раз,
+сколько задаёт `second_resource.tag` этой строки TMA. Этот слот ведёт в
+`ai.dll` SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`:
+метод RVA `0x2010` передаёт коллекцию при `AI+0x83c` в `FUN_10002980`
+(RVA `0x2980`), а она добавляет один DWORD через `FUN_100048f0` (RVA
+`0x48f0`). Каждый вызов копирует `0xffffffff`; входное слово не меняется.
+Поэтому тег `3` даёт `[0xffffffff, 0xffffffff, 0xffffffff]`. Это число
+начальных ячеек, а не назначение трёх ID.
+
+Изолированная x86-проверка вызовом оригинальной `ai.dll` подтвердила пары
+«число слотов/ёмкость» `0/0`, `1/17`, `17/17` и `18/34`. Все добавленные слова
+равны `0xffffffff`, а входное значение, контрольные поля, память вне заголовка
+массива и указатель стека сохраняются. Это подтверждает добавление начального
+слова, но не работу последующих обработчиков.
+
+Rust хранит такой снимок в отдельном `MissionAiStartupState` для каждого TMA-клана,
+а не в пакете сценария. При полной загрузке (`Full`) создаётся запись для каждого
+клана, в том числе если у него нет пути сценария или пути таблицы исследований. Счётчик
+берётся из сырых битов `second_resource.tag` как `u32` у обеих форм записи —
+`Standard` и `Spatial`; нулевой счётчик даёт существующее состояние с пустым
+списком. Статический режим предпросмотра не создаёт состояний AI.
+
+Загрузка отклоняет суммарный объём свыше 1 000 000 DWORD и резервирует память
+проверяемой операцией; это защита Rust от чрезмерного выделения памяти, а не
+ограничение оригинальной игры. Rust сохраняет начальные слова, но не
+восстанавливает работу последующих обработчиков и не назначает этим словам ID
+объектов. Этот массив также не является списком
+`BehaviorDistributorState.registered_objects`.
+
До регулярной работы Behavior настраивает расписание своего первого шага. Он
берёт текущее 32-битное время игровой очереди и назначает проверку через три
интервала по 64 мс, добавляя небольшую случайную задержку от 0 до 63 мс. Эта
@@ -196,9 +253,10 @@ SuperAI для записей кланов, затем обновляет Behavi
от повторного входа; при занятой защите вызов пропускается, но одноразовая метка
всё равно устанавливается. В полной загрузке Rust вызывающая сторона передаёт
значение времени очереди; после регистрации объектов загрузчик последовательно
-подготавливает каждый сценарный пакет и выполняет только подтверждённые действия
-`Handler(19)` из первого `Init`. Статический просмотр не запускает Behavior, но
-может сохранить подтверждённые записи `Init` в сценарном varset.
+подготавливает каждый сценарный пакет и выполняет восстановленные действия
+`Handler(19)` и поддержанные формульные присваивания из первого `Init`. Статический
+просмотр не запускает Behavior, но может сохранить эти записи `Init` в сценарном
+varset.
Это полезное начало сценария, но ещё не целая игровая жизнь AI. Текущий runtime
не воспроизводит аргумент обратного вызова в `AI+0x3fc`, защиту от повторного
@@ -243,6 +301,25 @@ Rust-тест повторно читает лицензированную миÑ
TMA-записями и проверяет реальные `ObjectHandle`, но помечен `#[ignore]`, чтобы
не требовать оригинальных ресурсов от остальных разработчиков.
+После подготовки Control-таблицы можно разобрать и её начальную группировку для
+Behavior. Важны текущая метка записи и её порядковый номер в общей таблице:
+метка `1` начинает пустую группу, метка `2` становится кандидатом последней
+группы, а метка `30` записывает строку в отдельный список той же группы.
+Кандидат, встретившийся до первого заголовка, остаётся в списке без группы;
+ранняя метка `30` игнорируется. Например,
+для текущих меток `[2, 30, 1, 30, 2]` получаются незагруппированный кандидат
+`[0]` и группа с заголовком `2`, кандидатом `[4]` и строкой с меткой `30` под
+порядковым номером `[3]`. Все номера начинаются с нуля и отсчитываются по общей
+Control-таблице, а не по ключам устройств или записям CTLD.
+
+Полная загрузка Rust сохраняет это начальное состояние рядом с подготовленными
+Control-таблицами по исходному TMA ID и `ObjectHandle`. Если таблица недоступна,
+проекция сохраняет причину ошибки; доступная пустая таблица означает пустую
+группировку. При предварительном просмотре список группировок не создаётся. Здесь
+разбирается только подготовленный порядок текущих Control-дескрипторов. Проекция
+не устанавливает, когда список присоединяется к живому Behavior, как он затем
+обновляется и как работает полный игровой цикл.
+
Список распределителя хранит не только участников, но и долю каждого. При
добавлении двух объектов оба получают по половине общего веса. Если добавить
третий, движок оставляет долю новой записи около одной трети, а оставшийся вес
@@ -468,16 +545,117 @@ Tag выбирает конкретное правило. Для tag 1 запиÑ
### TRF и preload-данные
-TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
-variants имеют сигнатуру [NRes](../reference/nres.md) и содержат большие
-таблицы имён игровых прототипов: оружия, башен, сооружений и других объектов.
-Также найдены preload-записи, ANI и SKE resources.
+TRF-архивы используют несколько потребителей; один подтверждённый путь ведёт
+из `Handler(43)` к вычислению стоимости исследования. В проверенной сборке GOG
+`CalcFullResearchCost` из `ArealMap.dll` получает таблицу, загруженную через
+`MisLoad.dll`. Для этого пути `TRF0` содержит строки по десять сырых `u32`,
+`TRF1` — байты флагов, `TRFB` — пары «смещение имени и номер строки», а
+`TRF6` — пул строк с нулевыми окончаниями. Слово 8 строки связывает её с
+`TRFB` через младшие 16 бит; старшая половина сохраняется как неизвестное
+значение. `TRF2` и `TRF3` образуют упорядоченные списки предварительных
+исследований, которые читает этот путь стоимости. `TRF4`/`TRF5` образуют
+отдельную пару таблиц, но `CalcFullResearchCost` её не использует. Непрозрачные
+`TRF7`–`TRFA` остаются в архиве и требуются загрузчику.
+
+Модуль `fparkan-assets` разбирает этот профиль в `ResearchTrfDocument` и
+вычисляет пару результатов для одного Unit DAT:
-По содержимому, порядку загрузки и consumers TRF с высокой вероятностью
-предоставляет AI/сценарному слою заранее подготовленную таблицу типов и
-связанных данных. Framing и имена подтверждены corpus-ом, но полная семантика
-каждой TRF-записи ещё не закрыта. Имена должны разрешаться через тот же
-resource registry, что и миссионные объекты.
+```rust
+let table = ResearchTrfDocument::decode(archive_bytes)?;
+let cost = calc_full_research_cost(&candidate, &table)?;
+```
+
+Расчёт проходит компоненты Unit DAT в порядке дерева. Компонент с нулевым
+`kind` завершает весь свой участок вместе с потомками. Для остальных ключ
+ресурса ищется среди имён `TRFB`; повтор строки добавляется лишь один раз.
+После прямых компонентов алгоритм обходит список `TRF2`/`TRF3` в его исходном
+порядке, углубляясь сразу в найденные предварительные исследования. Общая
+таблица посещений не даёт повторно добавлять узел и завершает циклы. Два бита
+флагов `TRF1` управляют включением строки и обходом её зависимостей; остальные
+сырые биты и слова не получают игровых имён без отдельного доказательства.
+
+Результат первого native-выхода подписан в отладочной строке как ore и
+суммирует слово 1 `TRF0`, второй подписан как power и суммирует слово 0. Каждое
+сложение сохраняется как `f32` в порядке найденной последовательности. В
+принятом наборе из 23 синтетических тестов оригинальных DLL проверялись
+`CalcFullResearchCost` и два AI-предиката; именно вызовы `CalcFullResearchCost`
+подтвердили порядок обхода, дедупликацию, циклы, флаги и отсечение дерева.
+Две проверки суммы показали чувствительность округления: одна и та же сумма
+`1, 2^24, -2^24` даёт `0`, а в другом порядке `2^24, -2^24, 1` даёт `1`.
+
+Эти результаты относятся к синтетическому исследовательскому контексту;
+штатный Handler(43) и запуск миссии здесь не проверялись.
+
+Владелец начальной записи исследования — индекс текущего прохода по кланам.
+В GOG-сборке `iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) проходит записи кланов
+и вызывает для каждой `FUN_100392e0` (RVA `0x392e0`), которая создаёт AI;
+`ai.dll!CreateSuperAI` (RVA `0xf710`) выделяет объект и вызывает конструктор
+по RVA `0x1000`, не проверяя наличие сценарного пути. Для исследовательского
+пути нужны отдельный флаг включения и непустой `second_resource.path`; наличие
+сценарного пакета условием не является. Индекс клана сохраняется в
+`descriptor[0]` и переносится в контекст, чтобы связать запрос с AI-данными
+того же клана.
+
+Это значение отдельно от аргумента 1 `AddNewObjectToGame`, берущегося из
+`GetNetPlayerNum`, и от TMA-тега `second_resource`, задающего число вызовов
+AI-слота `+0x38`. Передача поля подтверждена по статическому коду; выполнение
+Handler(43) и штатный запуск миссии остаются непроверенными. Технические
+сведения и адреса исходных сборок приведены в [границах знания](../appendices/knowledge-boundaries.md).
+
+Полный Rust-загрузчик принимает явный `MissionStartupInput`: время очереди
+и отдельный `research_creation_gate`. Режим миссии и имя файла этот
+флаг не заменяют. При включённом флаге загрузчик использует уже имеющееся
+`MissionAiStartupState` каждого TMA-клана, даже если у него нет сценарного
+пакета; путь берётся из второго ресурса как C-строка до первого NUL. Пустой
+путь и выключенный флаг пропускают чтение, а не-ASCII байты дают явную ошибку.
+Предпросмотр тоже не читает TRF. Клан индексируется по порядку в TMA, а тег
+`second_resource` задаёт число слотов AI, не владельца. Если несколько кланов
+указывают на один файл, каждый получает отдельно декодированную таблицу со
+своими строками TRF0.
+
+```rust
+MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: true,
+}
+```
+
+Это сохраняет декодированные таблицы и связь с владельцами для следующих
+этапов. Оно ещё не создаёт полный исследовательский контекст оригинала, не
+строит AI-каталог и не запускает `Handler(43)`; эти части пока не
+восстановлены.
+
+Отдельная frozen-матрица непосредственно вызвала chooser GOG `ai.dll` SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`: функция
+`FUN_10010f90` (RVA `0x10f90`) прошла три guard-case и все 15 проверок.
+Конструктор находится в RVA `0x10480`, raw DAT parser — `0x102c0`,
+предикаты — `0x115f0` и `0x11530`. Матрица передавала кандидатов в заданном
+порядке. Обёртка `Handler(43)` по RVA `0xd54a` и загрузчик AI-каталога по RVA
+`0x104e0` не вызывались. Cost принадлежит `ArealMap.dll` SHA-256
+`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`,
+`CalcFullResearchCost` RVA `0x16280`; для сравнения x87 был явно задан как
+`0x007f`.
+
+`ResearchCandidateSet` держит упорядоченный каталог, выбранный индекс, байт
+scan-status и флаг `loaded` каталога; TRF-контекст передаётся отдельно.
+При наличии каталога и контекста set-status начинает со значения `1`, затем
+перед чтением каждого entry со status `0` становится `0`. Ненулевой entry
+пропускается. Первый предикат требует info words 1 и 2, соответствующие битам
+TRF1 0 и 2; второй требует words 0 и 2, то есть биты 1 и 2. Второй status
+сохраняется в entry, а cost вызывается даже при status `1`. Выбор записывается
+сразу, когда second predicate ложен и ore строго ниже текущего минимума,
+изначально `f32::from_bits(0x4b18967f)`. Равенство сохраняет первого, NaN индекс не меняет.
+
+В Rust ошибка VFS-чтения и невалидный DAT пропускают entry; неизвестный TRF-
+ключ даёт false predicate. Ошибка стоимости `EmptyResearchTable` тоже
+пропускает entry и сохраняет его второй status. Остальные ошибки таблицы и
+`UnsupportedNonAsciiLookup` возвращаются явно, причём уже сделанные изменения
+индекса и status остаются. Общий Rust-предикат может закончить обход раньше
+native callback `0x11530`, который продолжает накапливать неиспользуемые float
+sums. Первый проход уже проверил enabled keys в той же неизменной таблице,
+поэтому подтверждается эквивалентность состояния и выбора для этого входа,
+но не callback-trace parity. Это не подтверждение цены на живой миссии или
+порядка AI-каталога.
### Стабильность AI-слоя
@@ -883,21 +1061,41 @@ descriptor tag `19` и положительным selector-0 ответом; пÑ
его vtable slot `+0x10` статически указывает на общий getter RVA `0x21d00`, но
отдельный aggregate oracle этот класс не запускал. Rust хранит начальный scale
factory `9` и предоставляет `query_loaded_mission_control_resource` для selector
-`1`/`2` и `set_loaded_mission_control_multiplier` для setter-а `1`. Вызовы
-сначала проверяют живой `ObjectHandle`, затем обращаются к таблице этого объекта
-из полной загрузки миссии. Синтетические тесты проверяют границы пустой таблицы,
-нулевого знаменателя, неподдержанных factory tags и чисел за диапазоном
-нормального `f32`; лицензированный тест сверяет реальные descriptor tags и
-получает для `min_l_n1.dat` сумму `16` и отношение `1` до setter-а.
-
-В этом API пока реализованы только getter selectors `1`/`2` и setter `1`.
+`1`, `2` и `15`, а также `set_loaded_mission_control_multiplier` для setter-а
+`1`. Вызовы сначала проверяют живой `ObjectHandle`, затем обращаются к таблице
+этого объекта из полной загрузки миссии. Синтетические тесты проверяют границы
+пустой таблицы, нулевого знаменателя, отсутствующих объектов фабрик и чисел за
+диапазоном нормального `f32`; лицензированный тест сверяет реальные descriptor
+tags и получает для `min_l_n1.dat` сумму `16` и отношение `1` до setter-а.
+
+Сырой селектор `0x0f` соединяет два ранее созданных объекта владельца: у строки
+с `factory_tag = 9` берётся коэффициент и его текущий множитель, а у строки с
+`factory_tag = 21` — шесть текущих `coefficient_bits`. `INTO` может поменять
+`descriptor.tag`, не заменяя объект фабрики, поэтому query выбирает последние
+строки по `factory_tag`, а не по текущему tag. Если любой объект фабрики
+отсутствует, ответ — `None`. Иначе шесть коэффициентов складываются с индекса `5`
+к `0` с округлением до `f32` после каждого сложения; результат умножается на
+selector `0` фабрики `9` (`coefficient_bits[0] * class9_selector0_scale_bits`).
+Общие `multiplier_bits`, `node_provider_status` и `native_word_50_bits` в этой
+формуле не участвуют.
+
+Лицензионная проверка загружает AutoDemo с явно переданными
+`MissionStartupInput { queue_time_ms: 100_000, research_creation_gate: false }`
+и вызывает `query_loaded_mission_control_resource(..., 15)` для исходных
+объектов `0..5`; для `6` и `7` Rust возвращает `None`, потому что не хватает как
+минимум одного объекта фабрики. Native-биты для `0..5` приведены в технической
+главе. Проверка сверяет результат исходного геттера на снимке входных полей,
+подготовленном Rust. Время и флаг заданы самим тестом; они не доказывают
+начальный режим запуска AutoDemo в оригинале и не являются захватом его фабрик
+или состояния объекта Control.
+
Отдельный native setter selector `17`, tag-specific методы прочих factory tags,
их внешние эффекты и вызов из регулярного шага Behavior здесь не моделируются.
Числа должны оставаться конечными и нормальными либо точным нулём; произведения
-и отношения за поддерживаемым диапазоном `f32` возвращают явную ошибку, а не
-подменяются нулём или `None`. При проверке диапазона Rust оценивает операцию в
-`f64`, затем выполняет её в `f32`; это ограниченная проверка диапазона, а не
-эмуляция полного x87 accumulator-а.
+и суммы за поддерживаемым диапазоном `f32` возвращают явную ошибку, а не
+подменяются нулём или `None`. При проверке диапазона Rust сначала оценивает
+границы в `f64`, затем выполняет каждую операцию в `f32`; это сохраняет
+пошаговое округление этого getter-а, но не эмулирует весь x87 execution state.
Отдельная ветка того же GOG `AniMesh.dll` показывает, когда создаётся Behavior
для host object. В `FUN_10002ff0` (RVA `0x2ff0`, SHA-256
@@ -941,8 +1139,31 @@ NDP, а `FUN_1000a100` (RVA `0xa100`) добавляет их в общую та
последовательность provider-строк: её длина берётся из NDP, она не дополняется
до числа узлов MSH и не участвует во второй перенумерации ключа.
-Так этот селектор включён в цепочку добычи руды оригинала. В той же сборке
-`Behavior.dll` (SHA-256
+Индексный selector `0x400` выдаёт значение выбранной строки provider как есть.
+Сначала общий индекс устройств выбирает дескриптор, затем его ключ выбирает
+строку NDP; это разные номера. Результатом становится текущее type-1 значение
+этой строки, а не запас `CurrentOre`, предел или значение после умножения на
+коэффициент и `M`. Например, provider-строка с `0.625` возвращает `0.625`, а
+записанный в ней ноль тоже остаётся успешным результатом. Rust использует уже
+подготовленную таблицу и сообщает отдельную ошибку, если provider-таблица или
+строка по ключу недоступна. Этот запрос читает снимок состояния и сам не меняет
+таблицу и не запускает её обновление.
+
+У правила есть два разных подтверждения. Разбор AutoDemo нашёл восемь таблиц
+Control и 100 устройств; все 100 значений по ключам устройств в этом корпусе
+равны `1.0`, поэтому Rust-проверка подтверждает реальные ключи и доступность
+строк, но сама по себе не различает выбор по ключу и по номеру устройства.
+Отдельная x86-проверка исходного `Control.dll` включала шесть вызовов на
+синтетической таблице, где значения строк были заданы вручную и различались.
+Например, при номере устройства `2`, ключе `1` и нулевом слове
+`device+0x50` оригинал вернул `0.625` из выбранной строки. Точные биты ответов
+приведены в [техническом разборе](../appendices/script-vm.md#индексный-control-getter-сырой-селектор-0x400).
+Этот опыт не создавал оригинальные factory-объекты и не исполнял полную миссию:
+полный интерфейс IID `0x204`, интеграция Behavior и игровое поведение остаются
+непроверенными.
+
+В цепочке добычи руды оригинал использует описанный выше selector `0x300`.
+В той же сборке `Behavior.dll` (SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`)
`FUN_100186b0` (RVA `0x186b0`) перечисляет устройства типа `0x1a`, а
`FUN_100198e0` (RVA `0x198e0`) суммирует успешные запросы IID `0x204` с selector
@@ -1221,6 +1442,30 @@ descriptor через `INTO`. Так разные части Control могут
загруженной таблицы; обновление пока не подключено к игровым часам и не
заменяет полный Control tick.
+Устройство фабрики `2` имеет поле selector `6`, `+0x174`; конструктор задаёт ему
+ноль. Позднее Control может пересчитать его по паре строк записи и отдельному
+прототипу `BULL`. Только для поддержанной свежей формы одного корневого mesh
+`BULL` из `objects.rlb`, без дочерних компонентов, его NDP-строки сводятся к
+сумме Max; например, `[1.5, 2.25]`
+превращается в `3.75` с округлением `f32` после каждого шага:
+
+```rust
+let mut total = 0.0_f32;
+for row in ndp_rows {
+ total += row.max_volume;
+}
+```
+
+Пустая таблица даёт ноль; физическую единицу этому числу не приписываем. Пара
+сравнивается как две C-строки до первого NUL: хвост не учитывается, регистр не
+важен. Если после изменения любая строка пуста, старое `+0x174` сохраняется;
+пустая пара конструктора не вызывает пересчёт, потому что уже совпадает. Новые
+значения применяются при добавлении и первой активной записи `INTO` в исходном
+порядке. Непредставленные входы оставляют селектор недоступным, а ошибка чтения
+узнаваемого, но повреждённого ресурса отменяет новую загрузку и сохраняет
+прежние мир и RNG. Общая формула selector 35 и условия свежей формы разобраны в
+[техническом приложении](../appendices/script-vm.md).
+
Отдельный путь объекта класса `4` умножает интервал на контактный коэффициент и
передаёт изменение строке. В проверенном примере коэффициент `1`, интервал `2`,
и значение строки уменьшается на `2`; этот коэффициент не является ставкой
@@ -1354,7 +1599,7 @@ offsets 119–121. В `fr_l_gener.ctl` найдено 35 строк type 4, вк
`f_gener_ball` с offset 85.
Control.dll `+0x8B10` передаёт указатель текущей записи диспетчеру `+0x2D4B0`.
-Для tags `2`, `3`, `4`, `5`, `8`, `10`, `21` и `27` подтверждён общий читатель
+Для tags `2`, `3`, `4`, `5`, `8`, `10`, `17`, `21` и `27` подтверждён общий читатель
`+0x21D50`: таблица виртуальных методов tag `2` (`+0x3CBD8`) вызывает его через
обёртку `+0x29650`, остальные перечисленные vtable указывают прямо на него в
позиции `+0x1C`. Читатель потребляет
@@ -1365,6 +1610,28 @@ Control.dll `+0x8B10` передаёт указатель текущей запÐ
а также отказ при оборванном завершающем NUL. Это подтверждает структурный
формат, но не назначает смысл всем коэффициентам устройств.
+Путь записи с меткой `17` проверен на GOG `Control.dll` с SHA-256
+`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`:
+ветвь `0x11` фабрики `+0x2D4B0` вызывает конструктор `+0x24760`, который
+устанавливает указатель на виртуальную таблицу `0x1003C840`. Ячейка `+0x1C`
+этой таблицы ссылается прямо на общий читатель `+0x21D50`. В ресурсах
+`weapon.rlb:bm_m_01.ctl` и `weapon.rlb:bm_l_01.ctl` таблица привязок
+заканчивается на смещении `0x120`, где начинается запись с меткой `17`.
+Общий префикс размером `0xB0` байт включает саму метку; счётчик по `+0xAC` и
+слово длины в этих двух ресурсах равны нулю, поэтому указатель чтения
+переходит на `0x1D4`. Там начинаются `0x54` байта заголовка таблиц эффектов;
+таблицы содержат соответственно `4`, `2`, `2` и `2` строки по 100 байт и
+заканчиваются на `0x620`. Записи из реестра объектов `bm_m_01` и `bm_l_01`
+при обычной подготовке ресурсов сохраняют явно указанные списки точек CPT
+(`bm_m_a.cpt` и `bm_l_a.cpt`) и таблицы узлов NDP (`bm_m_01.ndp` и
+`bm_l_01.ndp`); в первой таблице эффектов находятся три строки type 4.
+Синтетический тест проверяет непустые массивы значений и строки у записи с
+меткой 17 и следующей записи с меткой 4, затем переход к таблице эффектов.
+Если считать 17 неизвестной, чтение остановится перед
+ней; общий разбор позволяет пройти запись и прочитать следующие таблицы,
+оставляя слова tag 17 числами без толкования. Обработчики этой метки и их
+игровое поведение ещё не восстановлены.
+
Resolver прототипов хранит явную registry-ссылку на `.cpt` отдельно от ссылки
на `.ctl`. Если строки type 4 разобраны и явная ссылка на CPT есть, подготовка
читает ровно указанные archive/name; имя CPT не выводится из имени CTL. В CPT
diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md
index 428f8c2..75f47e1 100644
--- a/tools/native-frame-capture/README.md
+++ b/tools/native-frame-capture/README.md
@@ -110,3 +110,41 @@ or `44`/`50` for BGRA. Pass the viewer log's `readback_format` value; `37` is
the verified value for the run above, not a universal surface format. The
converter uses the installed `System.Drawing` PNG encoder and adds no package
dependency.
+
+## Сравнение областей кадра
+
+`compare-frames.ps1` собирает пару «оригинал / FParkan» без масштабирования и
+считает средний RGB каждой указанной области. Координаты областей относятся к
+viewport, то есть к PNG просмотрщика. Оригинальный PNG сначала обрезается по
+`viewport` из JSON камеры. Имена выбираются для конкретной сцены: например,
+`sky`, `terrain_near`, `terrain_far`, `facade`, `opening`; невидимые в этом
+ракурсе области добавлять не нужно. Панели HUD и движущиеся объекты следует
+оставлять вне измеряемых прямоугольников.
+
+Пример `regions.json` для интерьера 1280 × 1024:
+
+```json
+[
+ {"name":"ceiling_near","rect":[250,50,550,170]},
+ {"name":"wall","rect":[120,400,200,150]},
+ {"name":"opening","rect":[650,550,220,100]}
+]
+```
+
+```powershell
+& '.\tools\native-frame-capture\compare-frames.ps1' -SelfTest
+& '.\tools\native-frame-capture\compare-frames.ps1' `
+ -NativePng 'target\captures\r1\constructor\interior.png' `
+ -ViewerPng 'target\captures\r1\constructor\interior-after.png' `
+ -CameraJson 'target\captures\r1\constructor\interior.json' `
+ -RegionsJson 'target\captures\r1\constructor\interior-regions.json' `
+ -OutputPrefix 'target\captures\r1\constructor\interior-comparison'
+```
+
+Результат — PNG с парой и отмеченными областями, а также JSON со средними
+значениями каналов в диапазоне 0–255. Это значения сохранённого изображения,
+без перевода в линейное цветовое пространство. Скрипт отклоняет несовпадающие
+размеры, области за границей кадра и существующие выходные файлы. Совпадение
+средних цветов не доказывает совпадение геометрии, поэтому пару нужно также
+просмотреть. Камера и фаза должны происходить из одного нативного захвата;
+интерьер снимается после настоящего входа игрока в здание.
diff --git a/tools/native-frame-capture/compare-frames.ps1 b/tools/native-frame-capture/compare-frames.ps1
new file mode 100644
index 0000000..2a83bf0
--- /dev/null
+++ b/tools/native-frame-capture/compare-frames.ps1
@@ -0,0 +1,106 @@
+[CmdletBinding(DefaultParameterSetName = 'Compare')]
+param(
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$NativePng,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$ViewerPng,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$CameraJson,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$RegionsJson,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$OutputPrefix,
+ [Parameter(Mandatory = $true, ParameterSetName = 'SelfTest')][switch]$SelfTest
+)
+
+$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.Drawing
+
+function Get-MeanRgb {
+ param([System.Drawing.Bitmap]$Bitmap, [System.Drawing.Rectangle]$Region)
+ if ($Region.X -lt 0 -or $Region.Y -lt 0 -or $Region.Width -le 0 -or $Region.Height -le 0 -or
+ $Region.Right -gt $Bitmap.Width -or $Region.Bottom -gt $Bitmap.Height) {
+ throw 'Region must be a nonempty rectangle within the image.'
+ }
+ $sum = [long[]]@(0, 0, 0)
+ for ($y = $Region.Top; $y -lt $Region.Bottom; $y++) {
+ for ($x = $Region.Left; $x -lt $Region.Right; $x++) {
+ $pixel = $Bitmap.GetPixel($x, $y)
+ $sum[0] += $pixel.R
+ $sum[1] += $pixel.G
+ $sum[2] += $pixel.B
+ }
+ }
+ $count = [long]$Region.Width * $Region.Height
+ return ,@($sum | ForEach-Object { [Math]::Round($_ / $count, 4) })
+}
+
+if ($SelfTest) {
+ $test = [System.Drawing.Bitmap]::new(2, 1)
+ try {
+ $test.SetPixel(0, 0, [System.Drawing.Color]::FromArgb(10, 20, 30))
+ $test.SetPixel(1, 0, [System.Drawing.Color]::FromArgb(30, 40, 50))
+ $mean = Get-MeanRgb $test ([System.Drawing.Rectangle]::new(0, 0, 2, 1))
+ if (($mean -join ',') -ne '20,30,40') { throw 'Mean RGB self-test failed.' }
+ $rejected = $false
+ try { Get-MeanRgb $test ([System.Drawing.Rectangle]::new(1, 0, 2, 1)) | Out-Null }
+ catch { $rejected = $true }
+ if (-not $rejected) { throw 'Out-of-bounds region was accepted.' }
+ Write-Output 'self-test: exact region RGB means and bounds rejection OK'
+ } finally { $test.Dispose() }
+ return
+}
+
+$pngPath = [System.IO.Path]::GetFullPath($OutputPrefix + '.png')
+$jsonPath = [System.IO.Path]::GetFullPath($OutputPrefix + '.json')
+if ((Test-Path -LiteralPath $pngPath) -or (Test-Path -LiteralPath $jsonPath)) {
+ throw 'Comparison output already exists; choose a new prefix.'
+}
+$camera = Get-Content -LiteralPath $CameraJson -Raw | ConvertFrom-Json
+if ($camera.schema -ne 'fparkan-legacy-camera-v1' -or $camera.viewport.Count -ne 4) {
+ throw 'Expected a legacy camera JSON with a four-value viewport.'
+}
+$viewport = @($camera.viewport)
+foreach ($value in $viewport) {
+ if ($value -lt 0 -or $value -gt [int]::MaxValue -or [Math]::Truncate($value) -ne $value) {
+ throw 'Viewport coordinates must be nonnegative integers.'
+ }
+}
+$crop = [System.Drawing.Rectangle]::new($viewport[0], $viewport[1], $viewport[2] - $viewport[0], $viewport[3] - $viewport[1])
+$native = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $NativePng).Path)
+$viewer = $nativeCrop = $pair = $graphics = $null
+try {
+ $viewer = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $ViewerPng).Path)
+ if ($crop.Width -le 0 -or $crop.Height -le 0 -or $crop.Right -gt $native.Width -or $crop.Bottom -gt $native.Height -or
+ $viewer.Width -ne $crop.Width -or $viewer.Height -ne $crop.Height) {
+ throw 'Native crop and viewer dimensions must match without scaling.'
+ }
+ $nativeCrop = $native.Clone($crop, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
+ $pair = [System.Drawing.Bitmap]::new($viewer.Width * 2, $viewer.Height + 24)
+ $graphics = [System.Drawing.Graphics]::FromImage($pair)
+ $graphics.Clear([System.Drawing.Color]::Black)
+ $graphics.DrawImageUnscaled($nativeCrop, 0, 24)
+ $graphics.DrawImageUnscaled($viewer, $viewer.Width, 24)
+ $font = [System.Drawing.SystemFonts]::DefaultFont
+ $graphics.DrawString('Original', $font, [System.Drawing.Brushes]::White, 4, 4)
+ $graphics.DrawString('FParkan', $font, [System.Drawing.Brushes]::White, $viewer.Width + 4, 4)
+ $regions = @(Get-Content -LiteralPath $RegionsJson -Raw | ConvertFrom-Json)
+ if ($regions.Count -eq 0) { throw 'At least one named region is required.' }
+ $report = foreach ($region in $regions) {
+ if ([string]::IsNullOrWhiteSpace($region.name) -or $region.rect.Count -ne 4) { throw 'Each region needs name and rect [x,y,width,height].' }
+ foreach ($value in $region.rect) {
+ if ($value -lt 0 -or $value -gt [int]::MaxValue -or [Math]::Truncate($value) -ne $value) { throw 'Region coordinates must be nonnegative integers.' }
+ }
+ $rect = [System.Drawing.Rectangle]::new($region.rect[0], $region.rect[1], $region.rect[2], $region.rect[3])
+ $originalMean = Get-MeanRgb $nativeCrop $rect
+ $viewerMean = Get-MeanRgb $viewer $rect
+ foreach ($offset in @(0, $viewer.Width)) {
+ $graphics.DrawRectangle([System.Drawing.Pens]::Yellow, $rect.X + $offset, $rect.Y + 24, $rect.Width, $rect.Height)
+ $graphics.DrawString($region.name, $font, [System.Drawing.Brushes]::Yellow, $rect.X + $offset, $rect.Y + 24)
+ }
+ [pscustomobject]@{ name = $region.name; rect = $region.rect; original_rgb = $originalMean; fparkan_rgb = $viewerMean }
+ }
+ $pair.Save($pngPath, [System.Drawing.Imaging.ImageFormat]::Png)
+ [pscustomobject]@{ native = $NativePng; viewer = $ViewerPng; camera = $CameraJson; rgb_encoding = 'stored image bytes, 0..255'; regions = @($report) } |
+ ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding UTF8
+ Write-Output "Wrote $pngPath and $jsonPath"
+} finally {
+ foreach ($resource in @($graphics, $pair, $nativeCrop, $viewer, $native)) {
+ if ($null -ne $resource) { $resource.Dispose() }
+ }
+}