aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock1
-rw-r--r--README.md40
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert9
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert.spvbin20096 -> 20196 bytes
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs23
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi.rs3
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/resources.rs41
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs502
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs91
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs103
-rw-r--r--apps/fparkan-game/Cargo.toml4
-rw-r--r--apps/fparkan-game/src/audio.rs120
-rw-r--r--apps/fparkan-game/src/camera_capture.rs268
-rw-r--r--apps/fparkan-game/src/main.rs10983
-rw-r--r--apps/fparkan-game/src/terrain_lod.rs286
-rw-r--r--crates/fparkan-assets/src/lib.rs515
-rw-r--r--crates/fparkan-fx/src/ambient_sound.rs288
-rw-r--r--crates/fparkan-fx/src/environment.rs74
-rw-r--r--crates/fparkan-fx/src/lib.rs1
-rw-r--r--crates/fparkan-fx/src/sky.rs67
-rw-r--r--crates/fparkan-render/src/lib.rs127
-rw-r--r--crates/fparkan-runtime/Cargo.toml1
-rw-r--r--crates/fparkan-runtime/src/lib.rs3806
-rw-r--r--crates/fparkan-script/src/lib.rs486
-rw-r--r--crates/fparkan-terrain-format/src/lib.rs37
-rw-r--r--crates/fparkan-terrain/src/lib.rs80
-rw-r--r--crates/fparkan-terrain/src/lod.rs301
-rw-r--r--docs/appendices/knowledge-boundaries.md320
-rw-r--r--docs/appendices/script-vm.md382
-rw-r--r--docs/reference/atmosphere.md65
-rw-r--r--docs/reference/environment-effects.md190
-rw-r--r--docs/reference/msh.md45
-rw-r--r--docs/reference/render-frame.md173
-rw-r--r--docs/tomes/05-render.md316
-rw-r--r--docs/tomes/06-behavior.md230
-rw-r--r--tools/native-frame-capture/NativeFrameCapture.cs324
-rw-r--r--tools/native-frame-capture/README.md45
-rw-r--r--tools/native-frame-capture/compare-frames.ps1106
38 files changed, 17101 insertions, 3352 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 354cd64..ff96caa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -639,6 +639,7 @@ version = "0.1.0"
dependencies = [
"fparkan-animation",
"fparkan-assets",
+ "fparkan-fx",
"fparkan-msh",
"fparkan-path",
"fparkan-platform",
diff --git a/README.md b/README.md
index d67cc98..dea37bc 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,12 @@
интерактивную сцену выбранной карты: terrain, все доступные MSH-компоненты
объектов, материалы, динамические тени, небо, погоду, lightning FX и звуковые
события. Это просмотр карты с полной графической сценой; прохождение миссии и
-игровое поведение юнитов пока не запускаются, а модели показываются в статической
-позе, заданной сохранёнными настройками узлов прототипа.
+игровое поведение юнитов пока не запускаются. Модели начинают с настроек узлов
+прототипа, а поддерживаемые Control-анимации и эффекты обновляются по общим
+часам сцены. Неподдерживаемые Control-треки остаются на сохранённой начальной
+позе.
+Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры;
+под основаниями строений сохраняется подробная сетка.
## Запуск
@@ -46,23 +50,21 @@ cargo run --release -p fparkan-game -- `
луна, flares, молнии, дождь и снег, point lights, динамические тени и связанные
звуковые события обновляются в каждом кадре.
-`--frames N` ограничивает запуск числом представленных кадров для
-smoke/readback-проверок;
-значение `0` означает бесконечный интерактивный запуск. `--control-clock-ms N`
-воспроизводит общий clock анимации MSH и материалов вместе с окружением от нуля
-до `N` за `--frames` кадров и требует не менее двух кадров. Пересоздание
-swapchain повторяет текущий sample времени и состояние мира; перед повторной
-отправкой обновляются camera projection и viewport-зависимая геометрия. Это не
-продвигает сцену или replay clock. Счётчик и replay clock переходят дальше
-после представленного кадра.
-`--legacy-camera-capture` читает сохранённый захват камеры и запускает
-воспроизводимый режим без
-free-flight управления. `--static-animation-frame <u16>` задаёт один кадр для
-всех узлов. Если параметр не указан, приложение берёт сохранённые настройки
-узлов из явной ссылки прототипа на `.ctl`; для узлов без такой настройки
-используется кадр `0`. Статическая поза не запускает игровой Control, AI или
-полное поведение юнитов.
-`--static-material-phase <u16>` выбирает фиксированную фазу материала.
+`--frames N` ограничивает запуск числом кадров для smoke/readback-проверок;
+значение `0` означает бесконечный интерактивный запуск. `--legacy-camera-capture`
+читает сохранённый захват камеры и запускает воспроизводимый режим без
+free-flight управления. В обычном запуске накопленный runtime clock обновляет
+MSH Control-анимацию, поддержанные Control FX и анимацию материалов.
+`--control-clock-ms <u32>` вместе с `--frames N` (не меньше двух) воспроизводит
+этот clock от нуля до заданного миллисекундного значения за выбранные кадры.
+
+`--static-animation-frame <u16>` задаёт один кадр для всех MSH-узлов и
+выключает выбор MSH-позы из Control clock. Без override приложение берёт
+начальные настройки узлов из явных `.ctl` bindings, затем применяет
+поддержанные Control-треки. Узлы без настройки остаются на кадре `0`. Это не
+запускает AI, миссионные события или полное поведение юнитов.
+`--static-material-phase <u16>` фиксирует фазу MSH и terrain материалов; фаза
+материала Control FX остаётся частью его normalized command timeline.
`--readback-out target/frame.raw` сохраняет последний кадр GPU. `--validation`
включает Vulkan validation layers и завершает запуск с ошибкой при предупреждении
или ошибке. Readback сохраняет байты в формате surface без преобразования;
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert
index d1e2e04..078a0d1 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert
@@ -130,12 +130,13 @@ float native_specular_power(float cosine, float power) {
}
float native_fog_factor(vec3 world_position) {
- float fog_span = frame.fog_distances.y - frame.fog_distances.x;
- float distance_to_camera = distance(world_position, frame.camera_position.xyz);
- if (fog_span <= 0.000001 || distance_to_camera <= frame.fog_distances.x) {
+ float fog_span_squared = frame.fog_distances.w - frame.fog_distances.z;
+ vec3 to_camera = world_position - frame.camera_position.xyz;
+ float distance_squared = dot(to_camera, to_camera);
+ if (fog_span_squared <= 0.000001 || distance_squared <= frame.fog_distances.z) {
return 0.0;
}
- return clamp((distance_to_camera - frame.fog_distances.x) / fog_span, 0.0, 1.0);
+ return clamp((distance_squared - frame.fog_distances.z) / fog_span_squared, 0.0, 1.0);
}
vec3 normalized_or(vec3 value, vec3 fallback) {
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
index f74b1f8..4fc9a1b 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
Binary files differ
diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
index f691b34..826e89a 100644
--- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs
+++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
@@ -1105,7 +1105,21 @@ fn project_land_msh_to_static_mesh_with_lod0(
pub fn project_land_msh_to_static_mesh_in_world_space(
terrain: &LandMeshDocument,
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
- let lod0 = static_terrain_lod0_assignments(terrain)?;
+ project_land_msh_lod_to_static_mesh_in_world_space(terrain, TerrainLod::Lod0)
+}
+
+/// Projects one authored terrain LOD without changing source positions or attributes.
+///
+/// # Errors
+/// Returns an error for invalid slot assignments or an empty selected LOD.
+pub fn project_land_msh_lod_to_static_mesh_in_world_space(
+ terrain: &LandMeshDocument,
+ lod: TerrainLod,
+) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ let mut lod0 = terrain
+ .face_lod_assignments()
+ .map_err(VulkanAssetMeshError::InvalidTerrainLodMapping)?;
+ lod0.retain(|assignment| assignment.lod == lod);
let indices = static_terrain_indices(terrain, &lod0)?;
let vertices = terrain
.positions
@@ -2118,6 +2132,13 @@ mod tests {
assert_eq!(mesh.draw_ranges[0].index_count, 6);
assert_eq!(mesh.draw_ranges[0].material_index, 0x0101);
+ let far = project_land_msh_lod_to_static_mesh_in_world_space(&terrain, TerrainLod::Lod1)
+ .expect("LOD1 terrain projection");
+ assert_eq!(far.indices, [3, 4, 5, 3, 4, 5]);
+ assert_eq!(far.draw_ranges.len(), 1);
+ assert_eq!(far.draw_ranges[0].material_index, 0x0202);
+ assert_eq!(far.vertices[3].position, [10.0, 10.0, 10.0]);
+
terrain.nodes_raw.clear();
assert!(matches!(
project_land_msh_to_static_mesh_in_world_space(&terrain),
diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs
index 9584405..b98fa0d 100644
--- a/adapters/fparkan-render-vulkan/src/ffi.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi.rs
@@ -22,7 +22,8 @@ mod validation;
pub use self::asset_mesh::{
node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
- node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh,
+ node38_pose_relative_to_root_from_hierarchy,
+ project_land_msh_lod_to_static_mesh_in_world_space, project_land_msh_to_static_mesh,
project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space,
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs
index 0b43565..fe71d12 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs
@@ -243,18 +243,25 @@ pub(super) fn create_vertex_buffer(
/// Replaces the bytes of a host-visible static vertex allocation.
///
-/// The live renderer waits for the device before calling this helper, so a
+/// The live renderer waits for submitted readers before calling this helper, so a
/// single shared allocation can safely carry camera-relative sky and weather
/// geometry between frames.
pub(super) fn write_static_mesh_vertex_buffer(
device: &VulkanLogicalDeviceProbe,
buffer: &VulkanAllocatedBuffer,
+ first_vertex: usize,
vertices: &[super::VulkanStaticVertex],
) -> Result<(), VulkanSmokeRendererError> {
let bytes = encode_static_vertices(vertices)?;
+ let offset = first_vertex
+ .checked_mul(14 * std::mem::size_of::<f32>())
+ .ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "static mesh vertex upload offset overflows address space",
+ })?;
write_mapped_buffer(
device,
buffer,
+ offset,
&bytes,
"vkMapMemory(static mesh vertex update)",
)
@@ -295,29 +302,28 @@ fn encode_static_vertices(
fn write_mapped_buffer(
device: &VulkanLogicalDeviceProbe,
buffer: &VulkanAllocatedBuffer,
+ offset: usize,
bytes: &[u8],
context: &'static str,
) -> Result<(), VulkanSmokeRendererError> {
- let byte_len =
- u64::try_from(bytes.len()).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
- context: "static mesh upload exceeds address space",
- })?;
- if byte_len == 0 {
- return Err(VulkanSmokeRendererError::InvalidStaticMesh {
- context: "static mesh upload cannot be empty",
- });
+ if bytes.is_empty() {
+ return Ok(());
}
// SAFETY: The caller validates that the allocation is large enough and
// waits for all queue work that could read it before this write.
let mapped = unsafe {
- device
- .device()
- .map_memory(buffer.memory, 0, byte_len, vk::MemoryMapFlags::empty())
+ device.device().map_memory(
+ buffer.memory,
+ 0,
+ vk::WHOLE_SIZE,
+ vk::MemoryMapFlags::empty(),
+ )
}
.map_err(|result| VulkanSmokeRendererError::VulkanOperation { context, result })?;
- // SAFETY: `mapped` covers exactly the byte slice copied below.
+ // SAFETY: The caller bounds the upload against the allocation. Mapping
+ // from zero also meets minMemoryMapAlignment for arbitrary vertex offsets.
unsafe {
- std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>(), bytes.len());
+ std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>().add(offset), bytes.len());
device.device().unmap_memory(buffer.memory);
}
Ok(())
@@ -351,12 +357,19 @@ pub(super) fn create_index_buffer(
pub(super) fn write_static_mesh_index_buffer(
device: &VulkanLogicalDeviceProbe,
buffer: &VulkanAllocatedBuffer,
+ first_index: usize,
indices: &[u32],
) -> Result<(), VulkanSmokeRendererError> {
let bytes = encode_static_indices(indices)?;
+ let offset = first_index.checked_mul(std::mem::size_of::<u32>()).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "static mesh index upload offset overflows address space",
+ },
+ )?;
write_mapped_buffer(
device,
buffer,
+ offset,
&bytes,
"vkMapMemory(static mesh index update)",
)
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
index 522f95f..1b50914 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
@@ -210,83 +210,46 @@ impl VulkanSmokeRenderer {
rgba8: vec![255, 255, 255, 0],
mip_levels: Vec::new(),
};
- // Fallback stages are immutable compatibility images. Keep one
- // allocation for each fallback kind and map every logical material
- // slot to it. Authored images remain one-per-slot because dynamic
- // phase uploads must never couple two materials.
+ // Equal source pixels and authored mip chains can share one image.
+ // Dynamic updates detach their slot before changing a shared image.
let texture_sources = if create_info.materials.is_empty() {
vec![
- (&fallback_texture, Some(0_usize)),
- (&fallback_detail_texture, Some(1_usize)),
- (&fallback_overlay_texture, Some(2_usize)),
- (&fallback_detail_texture, Some(1_usize)),
+ &fallback_texture,
+ &fallback_detail_texture,
+ &fallback_overlay_texture,
+ &fallback_detail_texture,
]
} else {
let mut sources = Vec::with_capacity(create_info.materials.len() * 4);
for material in &create_info.materials {
- sources.push((&material.texture, None));
- sources.push((
+ sources.push(&material.texture);
+ sources.push(
material
.detail_texture
.as_ref()
- .map_or(&fallback_detail_texture, |texture| texture),
- material.detail_texture.is_none().then_some(1_usize),
- ));
- sources.push((
+ .unwrap_or(&fallback_detail_texture),
+ );
+ sources.push(
material
.overlay_texture
.as_ref()
- .map_or(&fallback_overlay_texture, |texture| texture),
- material.overlay_texture.is_none().then_some(2_usize),
- ));
- sources.push((
+ .unwrap_or(&fallback_overlay_texture),
+ );
+ sources.push(
material
.overlay_detail_texture
.as_ref()
- .map_or(&fallback_detail_texture, |texture| texture),
- material.overlay_detail_texture.is_none().then_some(1_usize),
- ));
+ .unwrap_or(&fallback_detail_texture),
+ );
}
sources
};
- let mut textures = Vec::with_capacity(texture_sources.len());
- let mut texture_slot_indices = Vec::with_capacity(texture_sources.len());
- let mut fallback_image_indices = [None; 3];
- for (texture_source, fallback_kind) in texture_sources {
- let texture_index = if let Some(fallback_kind) = fallback_kind {
- if let Some(texture_index) = fallback_image_indices[fallback_kind] {
- texture_index
- } else {
- let image = match create_static_texture_image(
- &instance,
- &device,
- command_pool,
- texture_source,
- ) {
- Ok(image) => image,
- Err(error) => {
- for texture in &textures {
- destroy_allocated_image(&device, texture);
- }
- // SAFETY: These resources belong to this live device and are rolled back before it drops.
- unsafe { device.device().destroy_command_pool(command_pool, None) };
- destroy_allocated_buffer(&device, &index_buffer);
- destroy_allocated_buffer(&device, &vertex_buffer);
- return Err(error);
- }
- };
- let texture_index = textures.len();
- textures.push(image);
- fallback_image_indices[fallback_kind] = Some(texture_index);
- texture_index
- }
- } else {
- let image = match create_static_texture_image(
- &instance,
- &device,
- command_pool,
- texture_source,
- ) {
+ let (unique_sources, texture_slot_indices) = deduplicate_texture_sources(&texture_sources);
+ let mut textures = Vec::with_capacity(unique_sources.len());
+ for texture_source in unique_sources {
+ let image =
+ match create_static_texture_image(&instance, &device, command_pool, texture_source)
+ {
Ok(image) => image,
Err(error) => {
for texture in &textures {
@@ -299,12 +262,9 @@ impl VulkanSmokeRenderer {
return Err(error);
}
};
- let texture_index = textures.len();
- textures.push(image);
- texture_index
- };
- texture_slot_indices.push(texture_index);
+ textures.push(image);
}
+ let initial_texture_image_count = textures.len() as u64;
let material_alphas = if create_info.materials.is_empty() {
vec![[1.0, 0.0]]
} else {
@@ -404,6 +364,7 @@ impl VulkanSmokeRenderer {
device: Some(device),
swapchain: Some(swapchain),
command_pool,
+ pipeline_cache: vk::PipelineCache::null(),
swapchain_resources: None,
vertex_buffer: Some(vertex_buffer),
index_buffer: Some(index_buffer),
@@ -464,8 +425,26 @@ impl VulkanSmokeRenderer {
readback_copy_count: 0,
readback_byte_count: 0,
readback_fnv1a64: 0,
+ mesh_vertex_upload_bytes: 0,
+ mesh_index_upload_bytes: 0,
+ mesh_full_upload_bytes: 0,
+ initial_texture_image_count,
+ texture_detach_count: 0,
+ pipeline_creation_count: 0,
},
};
+ // SAFETY: This cache is owned by the renderer and only used from its
+ // exclusive methods; it outlives every pipeline creation and swapchain.
+ renderer.pipeline_cache = unsafe {
+ renderer
+ .device_ref()?
+ .device()
+ .create_pipeline_cache(&vk::PipelineCacheCreateInfo::default(), None)
+ }
+ .map_err(|result| VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreatePipelineCache",
+ result,
+ })?;
renderer.rebuild_swapchain_resources(false)?;
let device_ref = renderer.device_ref()?;
let swapchain_ref = renderer.swapchain_ref()?;
@@ -495,6 +474,12 @@ impl VulkanSmokeRenderer {
readback_copy_count: 0,
readback_byte_count: 0,
readback_fnv1a64: 0,
+ mesh_vertex_upload_bytes: 0,
+ mesh_index_upload_bytes: 0,
+ mesh_full_upload_bytes: 0,
+ initial_texture_image_count,
+ texture_detach_count: 0,
+ pipeline_creation_count: renderer.report.pipeline_creation_count,
};
Ok(renderer)
}
@@ -628,7 +613,7 @@ impl VulkanSmokeRenderer {
///
/// The initial mesh owns enough vertices for the scene's dynamic slots;
/// callers update those slots in place as camera-relative environment
- /// geometry moves. The device is idled before the host-visible write so
+ /// geometry moves. Submitted readers finish before the host-visible write so
/// no submitted command can observe a partial update.
pub fn update_vertices(
&mut self,
@@ -639,15 +624,8 @@ impl VulkanSmokeRenderer {
context: "updated static mesh vertex count differs from allocation",
});
}
- // SAFETY: The renderer owns the queue and all submitted work; waiting
- // for idle makes the shared vertex allocation quiescent.
- unsafe { self.device_ref()?.device().device_wait_idle() }.map_err(|result| {
- VulkanSmokeRendererError::VulkanOperation {
- context: "vkDeviceWaitIdle(static mesh vertex update)",
- result,
- }
- })?;
- write_static_mesh_vertex_buffer(self.device_ref()?, self.vertex_buffer_ref()?, vertices)
+ self.wait_for_submitted_frames()?;
+ write_static_mesh_vertex_buffer(self.device_ref()?, self.vertex_buffer_ref()?, 0, vertices)
}
/// Uploads the current shared mesh while retaining dynamic draw state.
@@ -658,6 +636,9 @@ impl VulkanSmokeRenderer {
/// transparency classification, and active counts owned by other setters
/// remain untouched. The allocations grow to the largest submitted frame
/// and never grow for a frame that fits the retained capacity.
+ /// `unchanged_vertices` and `unchanged_indices` retain prefixes already
+ /// uploaded by the caller; only the remaining suffixes are written. A
+ /// growing allocation receives the full slice, including its prefix.
///
/// A range with `index_count == 0` is valid and disables that range for the
/// frame. The mesh itself still needs at least one vertex and one index so
@@ -667,6 +648,8 @@ impl VulkanSmokeRenderer {
vertices: &[super::VulkanStaticVertex],
indices: &[u32],
geometry_ranges: &[super::VulkanDynamicDrawRange],
+ unchanged_vertices: usize,
+ unchanged_indices: usize,
) -> Result<(), VulkanSmokeRendererError> {
validate_geometry_upload(
vertices,
@@ -676,19 +659,17 @@ impl VulkanSmokeRenderer {
self.vertex_count,
self.index_capacity,
)?;
+ let vertex_upload =
+ geometry_upload_range(vertices.len(), unchanged_vertices, self.vertex_count)?;
+ let index_upload =
+ geometry_upload_range(indices.len(), unchanged_indices, self.index_capacity)?;
let vertex_grew = vertices.len() > self.vertex_count;
let index_grew = indices.len() > self.index_capacity;
- // SAFETY: The renderer owns the queue and all submitted work. Waiting
- // before either writing or replacing a shared allocation keeps old
- // command buffers from observing a partial upload.
- unsafe { self.device_ref()?.device().device_wait_idle() }.map_err(|result| {
- VulkanSmokeRendererError::VulkanOperation {
- context: "vkDeviceWaitIdle(dynamic mesh update)",
- result,
- }
- })?;
+ if !vertex_upload.is_empty() || !index_upload.is_empty() {
+ self.wait_for_submitted_frames()?;
+ }
let (new_vertex_buffer, new_index_buffer) = if vertex_grew || index_grew {
let instance = self.instance_ref()?;
@@ -712,9 +693,12 @@ impl VulkanSmokeRenderer {
None
};
if !vertex_grew {
- if let Err(error) =
- write_static_mesh_vertex_buffer(device, self.vertex_buffer_ref()?, vertices)
- {
+ if let Err(error) = write_static_mesh_vertex_buffer(
+ device,
+ self.vertex_buffer_ref()?,
+ vertex_upload.start,
+ &vertices[vertex_upload.clone()],
+ ) {
if let Some(buffer) = new_vertex_buffer.as_ref() {
destroy_allocated_buffer(device, buffer);
}
@@ -725,9 +709,12 @@ impl VulkanSmokeRenderer {
}
}
if !index_grew {
- if let Err(error) =
- write_static_mesh_index_buffer(device, self.index_buffer_ref()?, indices)
- {
+ if let Err(error) = write_static_mesh_index_buffer(
+ device,
+ self.index_buffer_ref()?,
+ index_upload.start,
+ &indices[index_upload.clone()],
+ ) {
if let Some(buffer) = new_vertex_buffer.as_ref() {
destroy_allocated_buffer(device, buffer);
}
@@ -740,8 +727,18 @@ impl VulkanSmokeRenderer {
(new_vertex_buffer, new_index_buffer)
} else {
let device = self.device_ref()?;
- write_static_mesh_vertex_buffer(device, self.vertex_buffer_ref()?, vertices)?;
- write_static_mesh_index_buffer(device, self.index_buffer_ref()?, indices)?;
+ write_static_mesh_vertex_buffer(
+ device,
+ self.vertex_buffer_ref()?,
+ vertex_upload.start,
+ &vertices[vertex_upload.clone()],
+ )?;
+ write_static_mesh_index_buffer(
+ device,
+ self.index_buffer_ref()?,
+ index_upload.start,
+ &indices[index_upload.clone()],
+ )?;
(None, None)
};
@@ -767,6 +764,19 @@ impl VulkanSmokeRenderer {
geometry_ranges,
&mut self.draw_order_dirty,
);
+ self.report.mesh_vertex_upload_bytes = self
+ .report
+ .mesh_vertex_upload_bytes
+ .saturating_add((vertex_upload.len() as u64).saturating_mul(56));
+ self.report.mesh_index_upload_bytes = self
+ .report
+ .mesh_index_upload_bytes
+ .saturating_add((index_upload.len() as u64).saturating_mul(4));
+ self.report.mesh_full_upload_bytes = self
+ .report
+ .mesh_full_upload_bytes
+ .saturating_add((vertices.len() as u64).saturating_mul(56))
+ .saturating_add((indices.len() as u64).saturating_mul(4));
Ok(())
}
@@ -776,7 +786,7 @@ impl VulkanSmokeRenderer {
///
/// The updated image must have the same dimensions and generated/authored
/// mip count as the texture allocated during renderer creation. The
- /// renderer idles its device before the transfer, so callers may invoke
+ /// renderer waits for submitted readers before the transfer, so callers may invoke
/// this between frame updates without taking ownership of Vulkan handles.
pub fn update_material_texture(
&mut self,
@@ -807,17 +817,7 @@ impl VulkanSmokeRenderer {
context: "dynamic material texture index is out of bounds",
});
}
- {
- let device = self.device_ref()?;
- // SAFETY: every submission made by this renderer uses this device; the
- // idle wait makes all descriptor image reads quiescent before upload.
- unsafe { device.device().device_wait_idle() }.map_err(|result| {
- VulkanSmokeRendererError::VulkanOperation {
- context: "vkDeviceWaitIdle(dynamic texture update)",
- result,
- }
- })?;
- }
+ self.wait_for_submitted_frames()?;
let shared_texture = self
.texture_slot_indices
.iter()
@@ -825,7 +825,7 @@ impl VulkanSmokeRenderer {
.count()
> 1;
if shared_texture {
- // A fallback image can serve many logical slots. Detach the
+ // One image can serve many logical slots. Detach the
// requested slot before uploading so this public update method
// keeps its one-material/one-slot semantics.
let image = {
@@ -836,6 +836,7 @@ impl VulkanSmokeRenderer {
let new_texture_index = self.textures.len();
self.textures.push(image);
self.texture_slot_indices[logical_texture_index] = new_texture_index;
+ self.report.texture_detach_count = self.report.texture_detach_count.saturating_add(1);
let device = self.device_ref()?;
let resources = self.resources_ref()?;
update_material_texture_descriptor(
@@ -976,11 +977,7 @@ impl VulkanSmokeRenderer {
},
)?;
self.draw_range_base_pipeline_states[range_index] = next_base;
- let pipeline_changed = range.pipeline_state != next_state;
range.pipeline_state = next_state;
- if pipeline_changed {
- self.rebuild_pipeline_resources()?;
- }
Ok(())
}
@@ -1049,7 +1046,6 @@ impl VulkanSmokeRenderer {
},
)?;
let next_state = apply_world_transparency(base_state, transparent);
- let pipeline_changed = range.pipeline_state != next_state;
range.pipeline_state = next_state;
// Calling this setter opts the range into the world queue. Fixed
// screen/sky/weather ranges remain `None` because their pass order is
@@ -1059,9 +1055,6 @@ impl VulkanSmokeRenderer {
Some(transparent),
&mut self.draw_order_dirty,
);
- if pipeline_changed {
- self.rebuild_pipeline_resources()?;
- }
Ok(())
}
@@ -1388,6 +1381,24 @@ impl VulkanSmokeRenderer {
})
}
+ fn wait_for_submitted_frames(&self) -> Result<(), VulkanSmokeRendererError> {
+ let fences: Vec<_> = self.frame_sync.iter().map(|sync| sync.fence).collect();
+ if fences.is_empty() {
+ return Ok(());
+ }
+ // SAFETY: Every graphics submission signals one of these owned fences.
+ // Presentation does not read mesh buffers, textures or descriptor sets.
+ unsafe {
+ self.device_ref()?
+ .device()
+ .wait_for_fences(&fences, true, u64::MAX)
+ }
+ .map_err(|result| VulkanSmokeRendererError::VulkanOperation {
+ context: "vkWaitForFences(shared resource update)",
+ result,
+ })
+ }
+
/// Draws and presents one indexed-triangle frame.
///
/// # Errors
@@ -1433,14 +1444,7 @@ impl VulkanSmokeRenderer {
if needs_point_light_growth {
// Updating every material's binding 6 and replacing the shared
// SSBO requires all prior command buffers to finish.
- // SAFETY: The renderer owns the logical device and no submission
- // can observe the replacement after this idle point.
- unsafe { self.device_ref()?.device().device_wait_idle() }.map_err(|result| {
- VulkanSmokeRendererError::VulkanOperation {
- context: "vkDeviceWaitIdle(point-light buffer growth)",
- result,
- }
- })?;
+ self.wait_for_submitted_frames()?;
let instance =
self.instance
.as_ref()
@@ -1464,14 +1468,7 @@ impl VulkanSmokeRenderer {
// The storage buffer is shared by all in-flight frames. Wait for
// every prior use before replacing the records so a setter cannot
// race a vertex shader from the previous frame.
- // SAFETY: The renderer owns the logical device and no submission
- // can observe the host write after this idle point.
- unsafe { self.device_ref()?.device().device_wait_idle() }.map_err(|result| {
- VulkanSmokeRendererError::VulkanOperation {
- context: "vkDeviceWaitIdle(material specular update)",
- result,
- }
- })?;
+ self.wait_for_submitted_frames()?;
let resources = self.resources_ref()?;
write_material_specular_buffer(
self.device_ref()?,
@@ -1683,11 +1680,12 @@ impl VulkanSmokeRenderer {
device,
swapchain,
self.command_pool,
+ self.pipeline_cache,
self.vertex_buffer_ref()?,
self.index_buffer_ref()?,
self.textures_ref()?,
&self.texture_slot_indices,
- &self.draw_ranges,
+ &self.draw_range_base_pipeline_states,
self.depth_request,
reuse_command_pool,
)?,
@@ -1706,32 +1704,14 @@ impl VulkanSmokeRenderer {
self.report.swapchain_image_count = swapchain_image_count;
self.report.swapchain_image_format = self.swapchain_ref()?.report.plan.format.format;
self.report.swapchain_image_usage = swapchain_image_usage;
+ self.report.pipeline_creation_count = self
+ .report
+ .pipeline_creation_count
+ .saturating_add(resources.pipelines.len() as u64);
self.swapchain_resources = Some(resources);
Ok(())
}
- /// Rebuilds only the swapchain-owned pipeline bundle after a structural
- /// range-state change. Draw-range setters can be called while a renderer
- /// is live, so all prior submissions are completed before handles are
- /// destroyed and recreated.
- fn rebuild_pipeline_resources(&mut self) -> Result<(), VulkanSmokeRendererError> {
- if self.swapchain_resources.is_none() {
- return Ok(());
- }
- let device = self.device_ref()?;
- // SAFETY: The renderer owns every submission on this device. Waiting
- // here makes the old pipelines, framebuffers, and command buffers
- // quiescent before their destruction.
- unsafe { device.device().device_wait_idle() }.map_err(|result| {
- VulkanSmokeRendererError::VulkanOperation {
- context: "vkDeviceWaitIdle(draw range pipeline update)",
- result,
- }
- })?;
- self.destroy_swapchain_resources();
- self.rebuild_swapchain_resources(true)
- }
-
/// Recomputes the stable command-recording order. Without explicit
/// backgrounds, opaque world ranges followed by the sorted transparent
/// world ranges replace the existing world slots. With explicit
@@ -2081,6 +2061,15 @@ impl VulkanSmokeRenderer {
fn destroy_device_owned_resources(&mut self) {
self.destroy_swapchain_resources();
if let Some(device) = self.device.as_ref() {
+ if self.pipeline_cache != vk::PipelineCache::null() {
+ // SAFETY: All pipeline creation has finished on this device.
+ unsafe {
+ device
+ .device()
+ .destroy_pipeline_cache(self.pipeline_cache, None)
+ };
+ self.pipeline_cache = vk::PipelineCache::null();
+ }
for texture in self.textures.drain(..) {
destroy_allocated_image(device, &texture);
}
@@ -2231,6 +2220,40 @@ impl Drop for VulkanSmokeRenderer {
}
}
+fn deduplicate_texture_sources<'a>(
+ sources: &[&'a super::VulkanStaticTexture],
+) -> (Vec<&'a super::VulkanStaticTexture>, Vec<usize>) {
+ let mut indices = std::collections::HashMap::new();
+ let mut unique = Vec::new();
+ let mut slots = Vec::with_capacity(sources.len());
+ for &source in sources {
+ let index = *indices.entry(source).or_insert_with(|| {
+ let index = unique.len();
+ unique.push(source);
+ index
+ });
+ slots.push(index);
+ }
+ (unique, slots)
+}
+
+fn geometry_upload_range(
+ count: usize,
+ unchanged: usize,
+ capacity: usize,
+) -> Result<std::ops::Range<usize>, VulkanSmokeRendererError> {
+ if unchanged > count || unchanged > capacity {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "unchanged mesh prefix exceeds uploaded or retained geometry",
+ });
+ }
+ Ok(if count > capacity {
+ 0..count
+ } else {
+ unchanged..count
+ })
+}
+
fn validate_geometry_upload(
vertices: &[super::VulkanStaticVertex],
indices: &[u32],
@@ -2405,6 +2428,18 @@ fn draw_material_alphas(
]
}
+pub(super) fn pipeline_variants(original: LegacyPipelineState) -> [LegacyPipelineState; 12] {
+ std::array::from_fn(|index| match index {
+ 0 => original,
+ 1 => apply_world_transparency(original, true),
+ _ => {
+ world_material_pipeline_states(original, ((index - 2) / 2) as u8, Some(index % 2 != 0))
+ .expect("categories 0..=4 are valid")
+ .1
+ }
+ })
+}
+
fn world_material_pipeline_states(
original: LegacyPipelineState,
category: u8,
@@ -2572,12 +2607,15 @@ mod tests {
use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh};
use super::{
apply_geometry_range_updates, apply_world_transparency, completed_readback_buffer_index,
- 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,
+ deduplicate_texture_sources, draw_material_alphas, geometry_upload_range,
+ mark_draw_order_dirty_on_activity_change, pipeline_variants, 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;
@@ -2731,6 +2769,93 @@ mod tests {
}
#[test]
+ fn pipeline_variants_keep_authored_alpha_and_prepare_both_depth_policies() {
+ let original = LegacyPipelineState {
+ alpha_test: true,
+ depth: LegacyDepthMode::TestWrite,
+ cull: LegacyCullMode::FrontFace,
+ depth_bias: LegacyDepthBias::ProjectedDecal,
+ ..LegacyPipelineState::default()
+ };
+ let states = pipeline_variants(original);
+ let keys: std::collections::BTreeSet<_> = states
+ .iter()
+ .map(|state| fparkan_render::PipelineKey::from(*state))
+ .collect();
+ assert_eq!(keys.len(), 12);
+ assert_eq!(states[0], original);
+ assert_eq!(states[1], apply_world_transparency(original, true));
+ for state in states {
+ assert_eq!(state.cull, original.cull);
+ assert_eq!(state.depth_bias, original.depth_bias);
+ }
+ let disabled = pipeline_variants(LegacyPipelineState {
+ depth: LegacyDepthMode::Disabled,
+ ..original
+ });
+ assert!(disabled
+ .iter()
+ .all(|state| state.depth == LegacyDepthMode::Disabled));
+ }
+
+ #[test]
+ fn texture_deduplication_compares_pixels_extents_and_authored_mips() {
+ use super::super::{VulkanStaticTexture, VulkanStaticTextureMip};
+ let base = VulkanStaticTexture {
+ width: 2,
+ height: 2,
+ rgba8: vec![255; 16],
+ mip_levels: Vec::new(),
+ };
+ let copy = base.clone();
+ let authored = VulkanStaticTexture {
+ mip_levels: vec![
+ VulkanStaticTextureMip {
+ width: 2,
+ height: 2,
+ rgba8: base.rgba8.clone(),
+ },
+ VulkanStaticTextureMip {
+ width: 1,
+ height: 1,
+ rgba8: vec![255; 4],
+ },
+ ],
+ ..base.clone()
+ };
+ let mut different_mip = authored.clone();
+ different_mip.mip_levels[1].rgba8[0] = 0;
+ let different_extent = VulkanStaticTexture {
+ width: 1,
+ height: 4,
+ ..base.clone()
+ };
+ let sources = [
+ &base,
+ &copy,
+ &authored,
+ &different_mip,
+ &different_extent,
+ &copy,
+ ];
+ for source in sources {
+ source.validate().expect("valid texture");
+ }
+ let (unique, slots) = deduplicate_texture_sources(&sources);
+ assert_eq!(unique.len(), 4);
+ assert_eq!(slots, [0, 0, 1, 2, 3, 0]);
+ }
+
+ #[test]
+ fn geometry_upload_preserves_prefix_until_allocation_grows() {
+ assert_eq!(geometry_upload_range(12, 8, 16), Ok(8..12));
+ assert_eq!(geometry_upload_range(8, 8, 16), Ok(8..8));
+ assert_eq!(geometry_upload_range(20, 8, 16), Ok(0..20));
+ assert!(geometry_upload_range(8, 9, 16).is_err());
+ assert!(geometry_upload_range(20, 17, 16).is_err());
+ }
+
+ #[test]
fn geometry_update_can_disable_empty_shadow_range_without_zero_sized_upload() {
let mesh = VulkanStaticMesh::smoke_triangle();
let geometry = [VulkanDynamicDrawRange {
@@ -3145,12 +3270,74 @@ mod tests {
}
#[test]
+ fn control_fx_queue_follows_opaque_and_sky_without_reactivating_empty_ranges() {
+ const SCREEN_GRADIENT: usize = 0;
+ const CONTROL_FX: usize = 1;
+ const TERRAIN: usize = 2;
+ const BUILDING: usize = 3;
+ const SHADOW: usize = 4;
+ const NEBULA: usize = 5;
+ const DOME: usize = 6;
+ const CLOUDS: usize = 7;
+ const EMPTY_CONTROL_FX: usize = 8;
+ const SUN: usize = 9;
+ const WEATHER: usize = 10;
+ let mut ranges = (0..11)
+ .map(|index| VulkanStaticDrawRange {
+ first_index: index * 3,
+ index_count: 3,
+ material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
+ alpha_test_reference: 0,
+ })
+ .collect::<Vec<_>>();
+ ranges[EMPTY_CONTROL_FX].index_count = 0;
+ let mut world_queue = vec![None; ranges.len()];
+ world_queue[TERRAIN] = Some(false);
+ world_queue[BUILDING] = Some(false);
+ world_queue[CONTROL_FX] = Some(true);
+ world_queue[EMPTY_CONTROL_FX] = Some(true);
+ let mut sort_keys = vec![None; ranges.len()];
+ sort_keys[CONTROL_FX] = Some(20.0);
+ sort_keys[EMPTY_CONTROL_FX] = Some(30.0);
+
+ let order = sorted_world_draw_order(
+ ranges.len(),
+ &ranges,
+ &world_queue,
+ &sort_keys,
+ &[NEBULA, DOME, CLOUDS],
+ );
+ assert_eq!(
+ order,
+ vec![
+ SCREEN_GRADIENT,
+ TERRAIN,
+ BUILDING,
+ SHADOW,
+ NEBULA,
+ DOME,
+ CLOUDS,
+ CONTROL_FX,
+ EMPTY_CONTROL_FX,
+ SUN,
+ WEATHER,
+ ]
+ );
+ assert_eq!(ranges[EMPTY_CONTROL_FX].index_count, 0);
+ }
+
+ #[test]
fn material_category_and_transparency_preserve_world_state_in_either_order() {
let original = LegacyPipelineState {
blend: LegacyBlendMode::Opaque,
depth: LegacyDepthMode::TestWrite,
cull: LegacyCullMode::FrontFace,
alpha_test: false,
+ depth_bias: LegacyDepthBias::ProjectedDecal,
};
let (base, category_first) =
world_material_pipeline_states(original, 1, Some(true)).expect("category 1");
@@ -3162,14 +3349,17 @@ mod tests {
assert!(base.alpha_test);
assert_eq!(base.depth, LegacyDepthMode::TestWrite);
assert_eq!(base.cull, LegacyCullMode::FrontFace);
+ assert_eq!(base.depth_bias, LegacyDepthBias::ProjectedDecal);
assert_eq!(category_first.depth, LegacyDepthMode::TestReadOnly);
assert_eq!(category_first.cull, LegacyCullMode::FrontFace);
+ assert_eq!(category_first.depth_bias, LegacyDepthBias::ProjectedDecal);
let fixed_state = LegacyPipelineState {
blend: LegacyBlendMode::Additive,
depth: LegacyDepthMode::Disabled,
cull: LegacyCullMode::Disabled,
alpha_test: false,
+ depth_bias: LegacyDepthBias::Disabled,
};
assert_eq!(
world_material_pipeline_states(fixed_state, 1, None),
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
index f004cb2..5d6f12f 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
@@ -284,6 +284,9 @@ impl VulkanFrameUniforms {
.chain([self.fog_start, self.fog_end])
.chain(self.camera_position)
.all(f32::is_finite)
+ && (self.fog_end <= self.fog_start
+ || ((self.fog_start * self.fog_start).is_finite()
+ && (self.fog_end * self.fog_end).is_finite()))
}
/// Serializes one frame slot in the exact `std140` member order.
@@ -344,7 +347,17 @@ impl VulkanFrameUniforms {
self.fog_color[2],
0.0,
]);
- values[84..88].copy_from_slice(&[self.fog_start, self.fog_end, 0.0, 0.0]);
+ let squared_fog_range = if self.fog_end > self.fog_start {
+ [self.fog_start * self.fog_start, self.fog_end * self.fog_end]
+ } else {
+ [0.0; 2]
+ };
+ values[84..88].copy_from_slice(&[
+ self.fog_start,
+ self.fog_end,
+ squared_fog_range[0],
+ squared_fog_range[1],
+ ]);
values[88..92].copy_from_slice(&[
self.camera_position[0],
self.camera_position[1],
@@ -546,7 +559,7 @@ impl VulkanStaticMaterial {
}
/// One authored RGBA8 mip level accepted by the Vulkan texture upload path.
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct VulkanStaticTextureMip {
/// Mip width in texels.
pub width: u32,
@@ -557,7 +570,7 @@ pub struct VulkanStaticTextureMip {
}
/// Decoded RGBA8 image accepted by the initial Vulkan texture upload path.
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct VulkanStaticTexture {
/// Image width in texels.
pub width: u32,
@@ -756,8 +769,8 @@ impl VulkanStaticMesh {
material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
- terrain_base_only: false,
pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
alpha_test_reference: 0,
}],
}
@@ -875,6 +888,31 @@ mod static_mesh_tests {
}
#[test]
+ fn fog_uniforms_match_live_native_squared_distance_range() {
+ // Terrain ShadeIndexedStrided: far=0x442389e6, far²=0x48d0f1ca;
+ // distance²=0x47707b04 gives visibility=0x3f5b2b94.
+ let frame = VulkanFrameUniforms {
+ fog_start: 0.0,
+ fog_end: f32::from_bits(0x4423_89e6),
+ ..Default::default()
+ };
+ assert!(frame.is_finite());
+ let bytes = frame.to_ne_bytes();
+ let read = |offset| f32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
+ assert_eq!(read(344), 0.0);
+ assert_eq!(read(348).to_bits(), 0x48d0_f1ca);
+ let visibility = 1.0 - (f32::from_bits(0x4770_7b04) - read(344)) / (read(348) - read(344));
+ assert!((visibility - f32::from_bits(0x3f5b_2b94)).abs() < 1.0e-6);
+ assert!(VulkanFrameUniforms::default().is_finite());
+ assert!(!VulkanFrameUniforms {
+ fog_start: 0.0,
+ fog_end: f32::MAX,
+ ..frame
+ }
+ .is_finite());
+ }
+
+ #[test]
fn static_camera_composes_recovered_d3d7_view_before_projection() {
let transform = RawCameraTransform {
words: [
@@ -992,8 +1030,8 @@ mod static_mesh_tests {
material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
- terrain_base_only: false,
pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
alpha_test_reference: 0,
}],
};
@@ -1023,8 +1061,8 @@ mod static_mesh_tests {
material_index: 7,
lightmap_index: u8::MAX,
batch_flags: 0,
- terrain_base_only: false,
pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
alpha_test_reference: 0,
},
VulkanStaticDrawRange {
@@ -1033,8 +1071,8 @@ mod static_mesh_tests {
material_index: 2,
lightmap_index: u8::MAX,
batch_flags: 0,
- terrain_base_only: false,
pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
alpha_test_reference: 0,
},
];
@@ -1101,6 +1139,11 @@ mod static_mesh_tests {
},
];
+ assert_eq!(
+ resolve_draw_texture_indices(&ranges, &materials),
+ Ok(vec![1, 0])
+ );
+
let mut screen_tl = materials[0].clone();
screen_tl.screen_tl = true;
screen_tl.sky_far_depth = true;
@@ -1109,11 +1152,9 @@ mod static_mesh_tests {
far_sky.unlit = true;
far_sky.sky_far_depth = true;
assert_eq!(far_sky.encoded_material_mode(), 5.0);
-
- assert_eq!(
- resolve_draw_texture_indices(&ranges, &materials),
- Ok(vec![1, 0])
- );
+ let mut nebula_with_stars = far_sky.clone();
+ nebula_with_stars.sky_nebula_stars = true;
+ assert_eq!(nebula_with_stars.encoded_material_mode(), 6.0);
let mut non_finite_base = materials.clone();
non_finite_base[0].specular_rgb[0] = f32::NAN;
@@ -1149,6 +1190,7 @@ mod static_mesh_tests {
fn alpha_cutoff_is_dynamic_and_disabled_outside_alpha_test_pipeline() {
let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0];
let disabled = VulkanStaticDrawRange {
+ terrain_base_only: false,
alpha_test_reference: 200,
..base
};
@@ -1157,6 +1199,7 @@ mod static_mesh_tests {
alpha_test: true,
..LegacyPipelineState::default()
},
+ terrain_base_only: false,
alpha_test_reference: 128,
..base
};
@@ -1167,6 +1210,7 @@ mod static_mesh_tests {
alpha_test: true,
..LegacyPipelineState::default()
},
+ terrain_base_only: false,
alpha_test_reference: 0,
..base
};
@@ -1176,6 +1220,7 @@ mod static_mesh_tests {
alpha_test: true,
..LegacyPipelineState::default()
},
+ terrain_base_only: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
..base
};
@@ -1288,6 +1333,18 @@ pub struct VulkanSmokeRendererReport {
pub readback_byte_count: u64,
/// Stable FNV-1a hash of the final completed readback artifact.
pub readback_fnv1a64: u64,
+ /// Vertex bytes written by dynamic geometry uploads, excluding initialization.
+ pub mesh_vertex_upload_bytes: u64,
+ /// Index bytes written by dynamic geometry uploads, excluding initialization.
+ pub mesh_index_upload_bytes: u64,
+ /// Bytes the same updates would write without retaining unchanged prefixes.
+ pub mesh_full_upload_bytes: u64,
+ /// Distinct GPU images allocated for initial material texture slots.
+ pub initial_texture_image_count: u64,
+ /// Shared images detached for independent material-slot updates.
+ pub texture_detach_count: u64,
+ /// Graphics pipelines created across swapchain lifetimes.
+ pub pipeline_creation_count: u64,
}
/// Measured validation counters from the live smoke loop.
@@ -1430,12 +1487,12 @@ pub struct VulkanSmokeRenderer {
pub(super) device: Option<VulkanLogicalDeviceProbe>,
pub(super) swapchain: Option<VulkanSwapchainProbe>,
pub(super) command_pool: vk::CommandPool,
+ pub(super) pipeline_cache: vk::PipelineCache,
pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
- /// Positional material slots mapped to the owned image list. Fallback
- /// stages may share one image without coupling independently animated
- /// material textures.
+ /// Positional material slots mapped to deduplicated images. Updating a
+ /// shared image first detaches that slot, preserving independent animation.
pub(super) texture_slot_indices: Vec<usize>,
pub(super) textures: Vec<VulkanAllocatedImage>,
pub(super) vertex_count: usize,
@@ -1472,8 +1529,8 @@ pub struct VulkanSmokeRenderer {
pub(super) material_selectors: Vec<u16>,
/// Whether the host-side specular records need a fence-safe GPU upload.
pub(super) material_specular_dirty: bool,
- /// Encoded material mode pushed at offset 124: 0 lit, 1 unlit, 2 native
- /// sky nebula/stars texture composite, 3 native lightmap composite.
+ /// Encoded material mode pushed at offset 124: low bits select the
+ /// combiner, bit 4 selects sky far-depth, and bit 8 selects screen/TL.
pub(super) material_unlit: Vec<f32>,
pub(super) readback_enabled: bool,
pub(super) camera: VulkanStaticCamera,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
index 05ce927..a44f79d 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;
@@ -173,11 +174,12 @@ pub(super) fn create_swapchain_resources(
device: &VulkanLogicalDeviceProbe,
swapchain: &VulkanSwapchainProbe,
command_pool: vk::CommandPool,
+ pipeline_cache: vk::PipelineCache,
vertex_buffer: &VulkanAllocatedBuffer,
index_buffer: &VulkanAllocatedBuffer,
textures: &[VulkanAllocatedImage],
texture_slot_indices: &[usize],
- draw_ranges: &[super::VulkanStaticDrawRange],
+ base_pipeline_states: &[LegacyPipelineState],
depth_request: DepthStencilSupport,
reuse_command_pool: bool,
) -> Result<VulkanSwapchainResources, VulkanSmokeRendererError> {
@@ -280,11 +282,12 @@ pub(super) fn create_swapchain_resources(
) = match create_swapchain_pipeline_bundle(
instance,
device,
+ pipeline_cache,
swapchain.report.plan.format.format,
swapchain.report.plan.extent,
textures,
texture_slot_indices,
- draw_ranges,
+ base_pipeline_states,
depth_request,
partial
.frame_uniform_buffer
@@ -448,11 +451,12 @@ fn create_swapchain_image_views(
fn create_swapchain_pipeline_bundle(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
+ pipeline_cache: vk::PipelineCache,
format: i32,
extent: (u32, u32),
textures: &[VulkanAllocatedImage],
texture_slot_indices: &[usize],
- draw_ranges: &[super::VulkanStaticDrawRange],
+ base_pipeline_states: &[LegacyPipelineState],
depth_request: DepthStencilSupport,
frame_uniform_buffer: &VulkanAllocatedBuffer,
material_specular_buffer: &VulkanAllocatedBuffer,
@@ -509,25 +513,31 @@ fn create_swapchain_pipeline_bundle(
}
destroy_depth_attachment(device, &depth_attachment);
})?;
- let pipelines =
- create_graphics_pipeline_cache(device, render_pass, pipeline_layout, extent, draw_ranges)
- .inspect_err(|_| {
- // SAFETY: These objects were created above on this live logical device and are destroyed on setup failure.
- unsafe {
- device
- .device()
- .destroy_pipeline_layout(pipeline_layout, None);
- device.device().destroy_sampler(sampler, None);
- device
- .device()
- .destroy_descriptor_pool(descriptor_pool, None);
- device
- .device()
- .destroy_descriptor_set_layout(descriptor_set_layout, None);
- device.device().destroy_render_pass(render_pass, None);
- }
- destroy_depth_attachment(device, &depth_attachment);
- })?;
+ let pipelines = create_graphics_pipeline_cache(
+ device,
+ pipeline_cache,
+ render_pass,
+ pipeline_layout,
+ extent,
+ base_pipeline_states,
+ )
+ .inspect_err(|_| {
+ // SAFETY: These objects were created above on this live logical device and are destroyed on setup failure.
+ unsafe {
+ device
+ .device()
+ .destroy_pipeline_layout(pipeline_layout, None);
+ device.device().destroy_sampler(sampler, None);
+ device
+ .device()
+ .destroy_descriptor_pool(descriptor_pool, None);
+ device
+ .device()
+ .destroy_descriptor_set_layout(descriptor_set_layout, None);
+ device.device().destroy_render_pass(render_pass, None);
+ }
+ destroy_depth_attachment(device, &depth_attachment);
+ })?;
Ok((
depth_attachment,
render_pass,
@@ -935,25 +945,39 @@ pub(super) fn draw_viewport(extent: (u32, u32), material_mode: f32) -> vk::Viewp
}
}
+fn depth_bias_parameters(depth_bias: LegacyDepthBias) -> (bool, f32, f32) {
+ match depth_bias {
+ LegacyDepthBias::Disabled => (false, 0.0, 0.0),
+ // shortcut: Vulkan constantFactor=-1.0/slopeFactor=0.0 is empirical; verify native raster state before claiming parity.
+ LegacyDepthBias::ProjectedDecal => (true, -1.0, 0.0),
+ }
+}
+
fn create_graphics_pipeline_cache(
device: &VulkanLogicalDeviceProbe,
+ pipeline_cache: vk::PipelineCache,
render_pass: vk::RenderPass,
pipeline_layout: vk::PipelineLayout,
extent: (u32, u32),
- draw_ranges: &[super::VulkanStaticDrawRange],
+ base_pipeline_states: &[LegacyPipelineState],
) -> Result<BTreeMap<PipelineKey, vk::Pipeline>, VulkanSmokeRendererError> {
let mut pipelines = BTreeMap::new();
- for range in draw_ranges {
- let key = range.pipeline_key();
+ for state in base_pipeline_states
+ .iter()
+ .copied()
+ .flat_map(super::smoke::pipeline_variants)
+ {
+ let key = PipelineKey::from(state);
if pipelines.contains_key(&key) {
continue;
}
let pipeline = match create_graphics_pipeline(
device,
+ pipeline_cache,
render_pass,
pipeline_layout,
extent,
- range.pipeline_state,
+ state,
) {
Ok(pipeline) => pipeline,
Err(error) => {
@@ -973,6 +997,7 @@ fn create_graphics_pipeline_cache(
fn create_graphics_pipeline(
device: &VulkanLogicalDeviceProbe,
+ pipeline_cache: vk::PipelineCache,
render_pass: vk::RenderPass,
pipeline_layout: vk::PipelineLayout,
extent: (u32, u32),
@@ -1053,6 +1078,8 @@ fn create_graphics_pipeline(
let viewport_state = vk::PipelineViewportStateCreateInfo::default()
.viewports(&viewports)
.scissors(&scissors);
+ let (depth_bias_enable, depth_bias_constant_factor, depth_bias_slope_factor) =
+ depth_bias_parameters(state.depth_bias);
let rasterization_state = vk::PipelineRasterizationStateCreateInfo::default()
.depth_clamp_enable(false)
.rasterizer_discard_enable(false)
@@ -1064,7 +1091,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);
@@ -1128,7 +1158,7 @@ fn create_graphics_pipeline(
let pipeline_result = unsafe {
device
.device()
- .create_graphics_pipelines(vk::PipelineCache::null(), &create_infos, None)
+ .create_graphics_pipelines(pipeline_cache, &create_infos, None)
};
// SAFETY: The shader modules were created above on this live logical device and are no longer needed after pipeline creation.
unsafe {
@@ -1337,7 +1367,20 @@ fn destroy_partial_swapchain_resources(
#[cfg(test)]
mod tests {
- use super::draw_viewport;
+ use super::{depth_bias_parameters, draw_viewport};
+ use fparkan_render::LegacyDepthBias;
+
+ #[test]
+ fn projected_decal_bias_parameters_use_empirical_vulkan_factors() {
+ assert_eq!(
+ depth_bias_parameters(LegacyDepthBias::Disabled),
+ (false, 0.0, 0.0)
+ );
+ assert_eq!(
+ depth_bias_parameters(LegacyDepthBias::ProjectedDecal),
+ (true, -1.0, 0.0)
+ );
+ }
#[test]
fn world_viewport_depth_keeps_distant_geometry_in_front_of_screen_sun() {
diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml
index bf0f540..4cb0326 100644
--- a/apps/fparkan-game/Cargo.toml
+++ b/apps/fparkan-game/Cargo.toml
@@ -14,6 +14,7 @@ fparkan-resource = { path = "../../crates/fparkan-resource" }
fparkan-render = { path = "../../crates/fparkan-render" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
fparkan-msh = { path = "../../crates/fparkan-msh" }
+fparkan-material = { path = "../../crates/fparkan-material" }
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
@@ -24,6 +25,3 @@ rodio = { version = "0.23.0", default-features = false, features = ["playback",
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
-
-[dev-dependencies]
-fparkan-material = { path = "../../crates/fparkan-material" }
diff --git a/apps/fparkan-game/src/audio.rs b/apps/fparkan-game/src/audio.rs
index 2de6464..a89e275 100644
--- a/apps/fparkan-game/src/audio.rs
+++ b/apps/fparkan-game/src/audio.rs
@@ -629,6 +629,7 @@ pub(crate) struct GameAudio {
music: Player,
variations: Player,
weather_loop: NativeSpatialPlayer,
+ control_sounds: BTreeMap<usize, NativeSpatialPlayer>,
one_shots: Vec<SpatialOneShot>,
library: AudioLibrary,
ambience: AmbienceScheduler,
@@ -672,6 +673,7 @@ impl GameAudio {
music,
variations,
weather_loop,
+ control_sounds: BTreeMap::new(),
one_shots: Vec::new(),
library,
ambience: AmbienceScheduler::new(random_seed(), random_seed()),
@@ -705,6 +707,11 @@ impl GameAudio {
self.weather_loop
.set_emitter_position(self.weather_position);
self.retain_active_one_shots();
+ self.control_sounds.retain(|_, player| !player.empty());
+ for player in self.control_sounds.values() {
+ player.set_left_ear_position(left);
+ player.set_right_ear_position(right);
+ }
for one_shot in &self.one_shots {
one_shot.player.set_left_ear_position(left);
one_shot.player.set_right_ear_position(right);
@@ -825,19 +832,74 @@ impl GameAudio {
SoundAction::OneShot => {
self.retain_active_one_shots();
let asset = self.dynamic_sound(&event.archive, &event.name)?;
- self.one_shots.push(self.new_spatial_one_shot(
+ self.one_shots.push(self.new_spatial_voice(
&asset,
event.position,
volume,
min_distance,
max_distance,
frequency_ratio,
+ false,
)?);
}
}
Ok(())
}
+ /// Each Control command owns its source; it must not replace the rain
+ /// loop or another instance using the same sample.
+ pub(crate) fn handle_control_sound(
+ &mut self,
+ id: usize,
+ event: &SoundEvent,
+ ) -> Result<(), AudioError> {
+ validate_point(event.position, "Control emitter")?;
+ let frequency_ratio = if event.frequency_ratio.is_finite() && event.frequency_ratio > 0.0 {
+ event.frequency_ratio
+ } else {
+ 1.0
+ };
+ match event.action {
+ SoundAction::StartLoop | SoundAction::OneShot => {
+ let asset = self.dynamic_sound(&event.archive, &event.name)?;
+ let voice = self.new_spatial_voice(
+ &asset,
+ event.position,
+ event.volume,
+ event.min_distance,
+ event.max_distance,
+ frequency_ratio,
+ event.action == SoundAction::StartLoop,
+ )?;
+ if let Some(previous) = self.control_sounds.insert(id, voice.player) {
+ previous.stop();
+ }
+ eprintln!("control_sound_start id={id} resource={}:{} loop={} position={:?} range={}/{} frequency={}", event.archive, event.name, event.action == SoundAction::StartLoop, event.position, event.min_distance, event.max_distance, event.frequency_ratio);
+ }
+ SoundAction::SetLoopVolume => {
+ if let Some(player) = self.control_sounds.get(&id) {
+ player.set_emitter_position(event.position);
+ player.set_sound_parameters(event.min_distance, event.max_distance);
+ player.set_volume(event.volume);
+ player.set_speed(frequency_ratio);
+ }
+ }
+ SoundAction::StopLoop => {
+ if let Some(player) = self.control_sounds.remove(&id) {
+ player.stop();
+ eprintln!("control_sound_stop id={id}");
+ }
+ }
+ }
+ Ok(())
+ }
+
+ pub(crate) fn control_sound_is_playing(&self, id: usize) -> bool {
+ self.control_sounds
+ .get(&id)
+ .is_some_and(|player| !player.empty())
+ }
+
/// Selects one of the original MUSIC/Track02..10.ogg files and loops it.
pub(crate) fn play_music_track(&mut self, track: u8) -> Result<(), AudioError> {
let bytes = self.library.music_bytes(track)?;
@@ -875,6 +937,13 @@ impl GameAudio {
one_shot.player.play();
}
}
+ for player in self.control_sounds.values() {
+ if paused {
+ player.pause();
+ } else {
+ player.play();
+ }
+ }
}
/// Focus callback used by the window event loop.
@@ -888,6 +957,9 @@ impl GameAudio {
self.music.stop();
self.variations.stop();
self.weather_loop.stop();
+ for player in self.control_sounds.values() {
+ player.stop();
+ }
for one_shot in &self.one_shots {
one_shot.player.stop();
}
@@ -904,7 +976,7 @@ impl GameAudio {
Ok(())
}
- fn new_spatial_one_shot(
+ fn new_spatial_voice(
&self,
asset: &SoundAsset,
emitter: [f32; 3],
@@ -912,6 +984,7 @@ impl GameAudio {
min_distance: f32,
max_distance: f32,
frequency_ratio: f32,
+ looping: bool,
) -> Result<SpatialOneShot, AudioError> {
validate_point(emitter, "emitter")?;
let (left, right) = ear_positions(self.listener)?;
@@ -919,7 +992,12 @@ impl GameAudio {
player.set_sound_parameters(min_distance, max_distance);
player.set_volume(volume);
player.set_speed(frequency_ratio);
- player.append(decoder_for(asset)?);
+ let source = decoder_for(asset)?;
+ if looping {
+ player.append(source.repeat_infinite());
+ } else {
+ player.append(source);
+ }
if self.paused {
player.pause();
} else {
@@ -1695,6 +1773,42 @@ mod tests {
})
.expect("update original rain loop volume");
assert_eq!(audio.weather_loop.volume(), 0.5);
+ let mut machine = SoundEvent {
+ archive: "sounds.lib".to_string(),
+ name: "f_gener.wav".to_string(),
+ position: [1.0, 2.0, 3.0],
+ volume: 0.0,
+ min_distance: 15.0,
+ max_distance: 100.0,
+ frequency_ratio: 1.0,
+ action: SoundAction::StartLoop,
+ };
+ audio
+ .handle_control_sound(0, &machine)
+ .expect("first machine");
+ audio
+ .handle_control_sound(1, &machine)
+ .expect("second machine");
+ assert!(audio.control_sound_is_playing(0));
+ assert!(audio.control_sound_is_playing(1));
+ assert!(!audio.weather_loop.empty());
+ audio.set_paused(true);
+ assert!(audio
+ .control_sounds
+ .values()
+ .all(|player| player.is_paused()));
+ audio.set_paused(false);
+ assert!(audio
+ .control_sounds
+ .values()
+ .all(|player| !player.is_paused()));
+ machine.action = SoundAction::StopLoop;
+ audio
+ .handle_control_sound(0, &machine)
+ .expect("stop one machine");
+ assert!(!audio.control_sound_is_playing(0));
+ assert!(audio.control_sound_is_playing(1));
+ assert!(!audio.weather_loop.empty());
audio
.update_listener([10.0, 20.0, 30.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
.expect("move listener");
diff --git a/apps/fparkan-game/src/camera_capture.rs b/apps/fparkan-game/src/camera_capture.rs
new file mode 100644
index 0000000..bc16807
--- /dev/null
+++ b/apps/fparkan-game/src/camera_capture.rs
@@ -0,0 +1,268 @@
+use super::{CapturedViewport, PreviewCameraFrame};
+use fparkan_render::{LegacyD3d7Projection, RawCameraTransform};
+use serde::Deserialize;
+
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+pub(super) struct LegacyCameraCapture {
+ pub(super) schema: String,
+ pub(super) render_input_usable: Option<bool>,
+ pub(super) selector0_words: [u32; 16],
+ pub(super) parented_camera: Option<bool>,
+ pub(super) effective_world_matrix_space: Option<String>,
+ pub(super) effective_world_matrix_words: Option<[u32; 16]>,
+ pub(super) viewport: [i32; 4],
+ pub(super) near_plane: f32,
+ pub(super) far_plane: f32,
+ pub(super) field_of_view_radians: f32,
+ pub(super) atmosphere_seconds: Option<f32>,
+ pub(super) sky_uv_clock: Option<SkyUvClock>,
+ pub(super) geometry_projection: Option<GeometryProjection>,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+pub(super) struct SkyUvClock {
+ pub(super) verified: bool,
+ pub(super) elapsed_milliseconds: u32,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+pub(super) struct GeometryProjection {
+ verified: bool,
+ viewport: [i32; 4],
+ near_plane: f32,
+ far_plane: f32,
+ field_of_view_radians: f32,
+}
+
+pub(super) fn selected_atmosphere_seconds(
+ command_line: Option<f32>,
+ capture: Option<&LegacyCameraCapture>,
+) -> Option<f32> {
+ command_line.or_else(|| capture.and_then(|capture| capture.atmosphere_seconds))
+}
+
+impl LegacyCameraCapture {
+ pub(super) fn preview_matrix_words(&self) -> Result<[u32; 16], String> {
+ match (
+ self.effective_world_matrix_space.as_deref(),
+ self.effective_world_matrix_words,
+ ) {
+ (Some("world"), Some(words)) => Ok(words),
+ (Some(_), _) => Err(
+ "legacy camera effective_world_matrix_space must be 'world'".to_string(),
+ ),
+ (None, Some(_)) => Err(
+ "legacy camera effective_world_matrix_words requires an explicit world-space marker"
+ .to_string(),
+ ),
+ (None, None) if self.parented_camera == Some(true) => Err(
+ "parented legacy camera requires an effective world-space matrix".to_string(),
+ ),
+ (None, None) => Ok(self.selector0_words),
+ }
+ }
+
+ pub(super) fn captured_viewport(&self) -> Result<CapturedViewport, String> {
+ let width = self.viewport[2]
+ .checked_sub(self.viewport[0])
+ .filter(|width| *width > 0)
+ .ok_or_else(|| "legacy camera capture has an invalid viewport width".to_string())?;
+ let height = self.viewport[3]
+ .checked_sub(self.viewport[1])
+ .filter(|height| *height > 0)
+ .ok_or_else(|| "legacy camera capture has an invalid viewport height".to_string())?;
+ Ok(CapturedViewport {
+ rect: self.viewport,
+ extent: [
+ u32::try_from(width)
+ .map_err(|_| "legacy camera viewport width exceeds u32".to_string())?,
+ u32::try_from(height)
+ .map_err(|_| "legacy camera viewport height exceeds u32".to_string())?,
+ ],
+ })
+ }
+
+ pub(super) fn preview_camera_frame(&self) -> Result<PreviewCameraFrame, String> {
+ let projection = if let Some(geometry) = &self.geometry_projection {
+ if !geometry.verified || geometry.viewport != self.viewport {
+ return Err(
+ "legacy camera geometry projection is unverified or has a different viewport"
+ .to_string(),
+ );
+ }
+ LegacyD3d7Projection {
+ viewport: geometry.viewport,
+ near_plane: geometry.near_plane,
+ far_plane: geometry.far_plane,
+ field_of_view_radians: geometry.field_of_view_radians,
+ }
+ } else {
+ LegacyD3d7Projection {
+ viewport: self.viewport,
+ near_plane: self.near_plane,
+ far_plane: self.far_plane,
+ field_of_view_radians: self.field_of_view_radians,
+ }
+ };
+ let frame = PreviewCameraFrame::from_legacy_d3d7(
+ RawCameraTransform {
+ words: self.preview_matrix_words()?,
+ },
+ projection,
+ )
+ .ok_or_else(|| "legacy camera capture contains an invalid D3D7 camera".to_string())?;
+ self.captured_viewport()?;
+ Ok(frame)
+ }
+}
+
+pub(super) fn load_legacy_camera_capture(
+ path: &std::path::Path,
+) -> Result<LegacyCameraCapture, String> {
+ let bytes = std::fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
+ let capture =
+ parse_legacy_camera_capture(&bytes).map_err(|err| format!("{}: {err}", path.display()))?;
+ if capture.geometry_projection.is_none() {
+ eprintln!("render_coverage captured_projection_unverified=1 (capture has no geometry_projection; using the recorded camera boundary, which can clip nearby surfaces)");
+ }
+ if capture.sky_uv_clock.is_none() {
+ eprintln!("render_coverage captured_sky_phase_unverified=1 (capture has no sky UV clock; using replay elapsed time)");
+ }
+ Ok(capture)
+}
+
+pub(super) fn parse_legacy_camera_capture(bytes: &[u8]) -> Result<LegacyCameraCapture, String> {
+ // Windows PowerShell 5.1 writes redirected text as UTF-16LE with a BOM.
+ // `capture-original-camera.ps1` intentionally emits plain JSON, so accept
+ // that normal hand-off format as well as UTF-8 without making the caller
+ // re-encode a read-only capture file.
+ let json = decode_legacy_camera_capture_json(bytes)?;
+ let capture: LegacyCameraCapture =
+ serde_json::from_str(&json).map_err(|err| format!("invalid legacy camera JSON: {err}"))?;
+ if capture.schema != "fparkan-legacy-camera-v1" {
+ return Err("unsupported legacy camera capture schema".to_string());
+ }
+ if capture.effective_world_matrix_space.is_some()
+ != capture.effective_world_matrix_words.is_some()
+ {
+ return Err(
+ "effective_world_matrix_space and effective_world_matrix_words must be supplied together"
+ .to_string(),
+ );
+ }
+ if capture.render_input_usable == Some(false) {
+ return Err("legacy camera capture is marked render_input_usable=false".to_string());
+ }
+ if capture
+ .sky_uv_clock
+ .as_ref()
+ .is_some_and(|clock| !clock.verified)
+ {
+ return Err("legacy camera sky UV clock is unverified".to_string());
+ }
+ if capture
+ .atmosphere_seconds
+ .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0)
+ {
+ return Err(
+ "legacy camera capture atmosphere_seconds must be finite and non-negative".to_string(),
+ );
+ }
+ capture.preview_camera_frame()?;
+ Ok(capture)
+}
+
+fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> {
+ let decode_utf16 = |words: Vec<u16>| {
+ String::from_utf16(&words)
+ .map_err(|err| format!("invalid UTF-16 legacy camera JSON: {err}"))
+ };
+ match bytes {
+ [0xff, 0xfe, rest @ ..] => {
+ let chunks = rest.chunks_exact(2);
+ if !chunks.remainder().is_empty() {
+ return Err("invalid UTF-16LE legacy camera JSON length".to_string());
+ }
+ decode_utf16(
+ chunks
+ .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
+ .collect(),
+ )
+ }
+ [0xfe, 0xff, rest @ ..] => {
+ let chunks = rest.chunks_exact(2);
+ if !chunks.remainder().is_empty() {
+ return Err("invalid UTF-16BE legacy camera JSON length".to_string());
+ }
+ decode_utf16(
+ chunks
+ .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
+ .collect(),
+ )
+ }
+ [0xef, 0xbb, 0xbf, rest @ ..] => std::str::from_utf8(rest)
+ .map(str::to_owned)
+ .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")),
+ _ => std::str::from_utf8(bytes)
+ .map(str::to_owned)
+ .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn json() -> serde_json::Value {
+ let words = [
+ 1.0_f32, 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,
+ ]
+ .map(f32::to_bits);
+ serde_json::json!({
+ "schema": "fparkan-legacy-camera-v1", "selector0_words": words,
+ "viewport": [0, 0, 1280, 1024], "near_plane": 5.0, "far_plane": 700.0,
+ "field_of_view_radians": 1.3,
+ "geometry_projection": {
+ "verified": true, "viewport": [0, 0, 1280, 1024],
+ "near_plane": 0.5, "far_plane": 700.0, "field_of_view_radians": 1.3,
+ }
+ })
+ }
+
+ #[test]
+ fn geometry_projection_preserves_surfaces_closer_than_restored_camera_near() {
+ let mut json = json();
+ let captured = parse_legacy_camera_capture(&serde_json::to_vec(&json).unwrap()).unwrap();
+ let camera = captured.preview_camera_frame().unwrap();
+ let point = [1.0, 0.0, 0.0, 1.0];
+ let depth = |matrix: [f32; 16]| {
+ (0..4)
+ .map(|row| point[row] * matrix[row * 4 + 2])
+ .sum::<f32>()
+ };
+ assert_eq!(camera.near_plane, 0.5);
+ assert!(depth(camera.clip_from_world) > 0.0);
+ json.as_object_mut().unwrap().remove("geometry_projection");
+ let old = parse_legacy_camera_capture(&serde_json::to_vec(&json).unwrap()).unwrap();
+ let old_camera = old.preview_camera_frame().unwrap();
+ assert_eq!(old_camera.near_plane, 5.0);
+ assert!(depth(old_camera.clip_from_world) < 0.0);
+ }
+
+ #[test]
+ fn geometry_projection_rejects_unverified_mismatched_or_invalid_input() {
+ for (field, value) in [
+ ("verified", serde_json::json!(false)),
+ ("viewport", serde_json::json!([0, 0, 640, 480])),
+ ("near_plane", serde_json::json!(0.0)),
+ ("far_plane", serde_json::json!(0.25)),
+ ] {
+ let mut json = json();
+ json["geometry_projection"][field] = value;
+ assert!(
+ parse_legacy_camera_capture(&serde_json::to_vec(&json).unwrap()).is_err(),
+ "{field}"
+ );
+ }
+ }
+}
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index 0dff5ea..bbf9ead 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -2,21 +2,39 @@
//! `FParkan` render-planning composition root.
+mod camera_capture;
+mod terrain_lod;
+
+#[cfg(test)]
+use camera_capture::parse_legacy_camera_capture;
+use camera_capture::{
+ load_legacy_camera_capture, selected_atmosphere_seconds, LegacyCameraCapture,
+};
use fparkan_animation::{AnimationTime, NodePoseBuffer};
use fparkan_assets::{
+ load_material_archive_name_texture_rgba8_and_phase_with_document_from_root,
load_material_name_texture_rgba8_and_phase_with_document_from_root,
load_resource_entry_bytes_from_root,
load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root,
- page_uv_transform, sample_control_node_times, sample_material_phase, ControlInstanceState,
- ControlSamplingError, Mat0Document, MaterialPhase, MaterialPhaseSample,
- PreparedComponentControl, PreparedControlGateProvider, PreparedControlNodeBinding,
- PreparedMaterialPhase, PreparedModel, PreparedTexture, PreparedTextureUsage, PreparedVisual,
- RgbaImage, StandaloneWearMaterialLoader,
+ page_uv_transform, sample_control_node_times, sample_material_normalized_phase,
+ sample_material_phase, ControlInstanceState, ControlSamplingError, Mat0Document, MaterialPhase,
+ MaterialPhaseSample, PreparedComponentControl, PreparedControlEffectRow,
+ PreparedControlEffectSection, PreparedControlGateProvider, PreparedControlNodeBinding,
+ PreparedControlPoint, PreparedControlPointList, PreparedMaterialPhase, PreparedModel,
+ PreparedTexture, PreparedTextureUsage, PreparedVisual, RgbaImage, StandaloneWearMaterialLoader,
};
-use fparkan_fx::atmosphere::TypedAtmosphere;
+use fparkan_fx::ambient_op3::{
+ evaluate_op3_type0_command, evaluate_op3_type1_command, evaluate_op3_type2_command,
+};
+use fparkan_fx::ambient_sound::{Op2SoundCommand, Op2SoundState};
+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 +44,21 @@ use fparkan_fx::sky::{
sprite_half_size_pixels, SkyFrame, SkyMaterials, SkyMesh, SkyPassKind, SkySpriteFrame,
SkySystem, SunVisibility, SKY_FLARE_ROWS,
};
+use fparkan_fx::smoke::{evaluate_op8_emitter, Op8DetailThresholdRange, SmokeEmitterState};
+use fparkan_fx::{advance_native_fx_random_state, decode_fxid, FxCommand, FxDocument, FxOpcode};
+#[cfg(test)]
+use fparkan_material::{MaterialAnimationBlock, MaterialKey};
use fparkan_msh::{
node38_fallback_hierarchy, node38_sampled_hierarchy, node38_sampled_hierarchy_at_times,
selected_slot, Group, Lod, ModelAsset, NodeId, SlotId,
};
use fparkan_path::ResourceName;
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
-use fparkan_prototype::{unit_component_tree, UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO};
+use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO};
use fparkan_render::{
- LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthMode,
- LegacyIron3dEulerTransform, LegacyPipelineState, RawCameraTransform,
+ legacy_msh_portal_alpha, LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection,
+ LegacyDepthBias, LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState,
+ RawCameraTransform,
};
use fparkan_render_vulkan::{
node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
@@ -46,22 +69,26 @@ use fparkan_render_vulkan::{
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount,
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount,
- VulkanDynamicDrawRange, VulkanNodePose, VulkanPointLight, VulkanSmokeFrameOutcome,
- VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticDrawRange,
- VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanStaticTextureMip,
- VulkanStaticVertex,
+ VulkanDynamicDrawRange, VulkanNodePose, VulkanPointLight, VulkanReadbackArtifact,
+ VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
+ VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh,
+ VulkanStaticTexture, VulkanStaticTextureMip, VulkanStaticVertex,
};
use fparkan_runtime::{
create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets,
- loaded_mission_object_drafts, loaded_terrain, query_building_segment_world, BuildingMeshNode,
- EngineServices, MissionAssets, MissionMode, MissionObjectDraft, MissionRequest,
+ loaded_mission_object_drafts, loaded_terrain, query_building_segment_world,
+ BasementPlacementMode, BuildingMeshNode, EngineServices, MissionAssets, MissionMode,
+ MissionObjectDraft, MissionRequest,
};
+#[cfg(test)]
+use fparkan_terrain::BasementFoundationFragment;
use fparkan_terrain::{
+ build_basement_foundations, BasementFootprint, BasementFoundation, BasementPlacement,
FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld,
};
use fparkan_vfs::DirectoryVfs;
-use serde::Deserialize;
use std::collections::{HashMap, HashSet};
+use std::io::Write;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -114,6 +141,10 @@ fn run(args: &[String]) -> Result<String, String> {
.map(load_legacy_camera_capture)
.transpose()?;
let atmosphere_seconds = selected_atmosphere_seconds(args.atmosphere_seconds, camera.as_ref());
+ let captured_sky_uv_ms = camera
+ .as_ref()
+ .and_then(|c| c.sky_uv_clock.as_ref())
+ .map(|c| c.elapsed_milliseconds);
let hold_schedule_phase = camera.is_some() && atmosphere_seconds.is_some();
let terrain_materials_duration_started = Instant::now();
let preview = static_preview_mesh_and_materials(
@@ -132,12 +163,15 @@ fn run(args: &[String]) -> Result<String, String> {
// sky to the selected map (there are many `sky.ske` files in the campaign
// tree).
let sky_started = Instant::now();
- let environment = load_environment(
+ let mut environment = load_environment(
&args.root,
&args.mission,
atmosphere_seconds,
hold_schedule_phase,
)?;
+ if let Some(environment) = &mut environment {
+ environment.fixed_sky_uv_ms = captured_sky_uv_ms;
+ }
let sky_duration = sky_started.elapsed();
let audio = match audio::GameAudio::new(Arc::new(DirectoryVfs::new(&args.root)), &args.mission)
{
@@ -153,11 +187,12 @@ fn run(args: &[String]) -> Result<String, String> {
environment,
audio,
args.frames,
- args.control_clock_ms,
&args.mission,
loaded.object_count,
args.readback_out.as_deref(),
+ args.readback_sequence_out.as_deref(),
args.validation,
+ args.control_clock_ms,
StartupTimings {
mission_assets: mission_assets_duration,
terrain_materials: terrain_materials_duration,
@@ -353,7 +388,6 @@ impl FreeFlightCamera {
self.position[2] = self.position[2].max(minimum_z);
Ok(())
}
-
fn forward(self) -> [f32; 3] {
let (sin_yaw, cos_yaw) = self.yaw.sin_cos();
let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
@@ -622,6 +656,10 @@ impl PreviewCameraFrame {
const ENVIRONMENT_SPRITE_ROWS: [usize; 2] = [3, 4];
const ENVIRONMENT_FLARE_SLOTS: usize = 12;
const ENVIRONMENT_PARTICLE_SLOTS: usize = 1_000;
+// shortcut: Effect.dll derives this from projected sphere size and a render-context
+// quality table. Category 0 supplies a highest-detail mesh until that exact
+// world category is available; this does not reproduce native LOD selection.
+const AMBIENT_EFFECT_NATIVE_CATEGORY: u8 = 0;
#[derive(Clone, Copy, Debug)]
struct EnvironmentGpuRange {
@@ -631,6 +669,207 @@ 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 ControlEffectSlot {
+ 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, Debug)]
+struct ControlSoundSlot {
+ effect: ControlEffectSlot,
+ command: Op2SoundCommand,
+ state: Op2SoundState,
+}
+
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+struct ControlEffectInstanceKey {
+ object_index: usize,
+ component_index: usize,
+ table_index: usize,
+ row_index: usize,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+struct ControlEffectRandomSample {
+ phase_jitter: f32,
+ accumulated_shift: [f32; 3],
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum SceneSampleId {
+ Live(u64),
+ Replay(u64),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct FrameClockSample {
+ id: SceneSampleId,
+ time_ms: u32,
+ delta_seconds: f32,
+}
+
+#[derive(Clone, Debug)]
+struct NativeFxRandom16 {
+ state: u32,
+}
+
+impl NativeFxRandom16 {
+ fn new(seed: u32) -> Self {
+ Self {
+ state: if seed == 0 { 0xA341_316C } else { seed },
+ }
+ }
+
+ fn next_u16(&mut self) -> u16 {
+ advance_native_fx_random_state(&mut self.state)
+ }
+
+ fn symmetric_sample(&mut self, amplitude: f32) -> f32 {
+ (f32::from(self.next_u16()) / 65_536.0 - 0.5) * amplitude
+ }
+}
+
+#[derive(Clone, Debug)]
+struct ControlEffectRandomState {
+ header_flags: u32,
+ phase_jitter_amplitude: f32,
+ random_shift_amplitude: [f32; 3],
+ phase_and_z_rng: NativeFxRandom16,
+ xy_rng: NativeFxRandom16,
+ sample: ControlEffectRandomSample,
+ last_update_id: Option<SceneSampleId>,
+ last_update_clock_ms: Option<u32>,
+}
+
+impl ControlEffectRandomState {
+ fn new(seed: u32, header_flags: u32, phase_jitter: f32, random_shift: [f32; 3]) -> Self {
+ Self {
+ header_flags,
+ phase_jitter_amplitude: phase_jitter,
+ random_shift_amplitude: random_shift,
+ phase_and_z_rng: NativeFxRandom16::new(seed ^ 0xA511_E9B3),
+ xy_rng: NativeFxRandom16::new(seed ^ 0x63D8_3595),
+ sample: ControlEffectRandomSample::default(),
+ last_update_id: None,
+ last_update_clock_ms: None,
+ }
+ }
+
+ fn advance(
+ &mut self,
+ sample_id: SceneSampleId,
+ control_clock_ms: u32,
+ initial_phase: f32,
+ ) -> ControlEffectRandomSample {
+ // Native updates consume process-global RNG streams. The preview uses
+ // a deterministic per-Control-row stream; sibling slots share one
+ // sample and cadence, but native cross-row RNG ordering remains open.
+ if self.last_update_id == Some(sample_id) {
+ return self.sample;
+ }
+ if self.header_flags & 1 != 0 && initial_phase != -1.0 {
+ self.sample.phase_jitter = self
+ .phase_and_z_rng
+ .symmetric_sample(self.phase_jitter_amplitude);
+ } else {
+ self.sample.phase_jitter = 0.0;
+ }
+ if self.header_flags & 8 != 0 {
+ let shift_z = self
+ .phase_and_z_rng
+ .symmetric_sample(self.random_shift_amplitude[2]);
+ let shift_y = self.xy_rng.symmetric_sample(self.random_shift_amplitude[1]);
+ let shift_x = self.xy_rng.symmetric_sample(self.random_shift_amplitude[0]);
+ self.sample.accumulated_shift[0] += shift_x;
+ self.sample.accumulated_shift[1] += shift_y;
+ self.sample.accumulated_shift[2] += shift_z;
+ }
+ self.last_update_id = Some(sample_id);
+ self.last_update_clock_ms = Some(control_clock_ms);
+ self.sample
+ }
+}
+
+#[derive(Clone, Debug)]
+struct PreparedEffectMaterial {
+ document: Mat0Document,
+ phase_material_indices: Vec<usize>,
+ phase_uv_transforms: Vec<[f32; 4]>,
+ phase_native_flag_40: Vec<bool>,
+ phase_directional_rgb: Vec<[f32; 3]>,
+ phase_additive_rgb: Vec<[f32; 3]>,
+ initial_pipeline_category: u8,
+}
+
#[derive(Clone, Debug)]
struct EnvironmentMaterialAsset {
texture: VulkanStaticTexture,
@@ -720,6 +959,23 @@ impl MaterialPhaseBinding {
})
}
+ fn sample_normalized(
+ &self,
+ material_index: usize,
+ normalized_phase: f32,
+ ) -> Result<MaterialPhaseSample, String> {
+ sample_material_normalized_phase(
+ &self.document,
+ self.animation_block_index,
+ normalized_phase,
+ )
+ .map_err(|error| {
+ format!(
+ "sample MAT0 material {material_index} at normalized phase {normalized_phase}: {error}"
+ )
+ })
+ }
+
fn page_uv_transform(&self, phase_index: usize) -> [f32; 4] {
self.phase_uv_transforms
.get(phase_index)
@@ -745,11 +1001,14 @@ impl MaterialPhaseBinding {
struct MaterialVariantSet {
phase_material_indices: Vec<usize>,
range_indices: Vec<usize>,
+ pipeline_category: u8,
}
#[derive(Clone, Debug)]
struct MaterialAnimationBinding {
initial_phase_index: usize,
+ normalized_phase_override: Option<f32>,
+ ambient_fx_material_alpha: bool,
variants: Vec<MaterialVariantSet>,
base: MaterialPhaseBinding,
detail: Option<MaterialPhaseBinding>,
@@ -758,10 +1017,39 @@ struct MaterialAnimationBinding {
stage_textures: Option<MaterialStageTextures>,
}
-#[derive(Clone, Copy, Debug, PartialEq)]
+#[derive(Clone, Copy, Debug)]
struct ActiveMaterialState {
material_index: usize,
+ phase_index: usize,
diffuse_alpha: f32,
+ additive_rgb: [f32; 3],
+}
+
+fn ambient_effect_material_alpha(is_ambient_effect: bool, sampled_opacity: f32) -> f32 {
+ if is_ambient_effect {
+ 1.0
+ } else {
+ sampled_opacity
+ }
+}
+
+fn sample_material_animation_base(
+ binding: &mut MaterialAnimationBinding,
+ clock_ms: u32,
+) -> Result<MaterialPhaseSample, String> {
+ match binding.normalized_phase_override {
+ Some(normalized_phase) => binding
+ .base
+ .sample_normalized(binding.initial_phase_index, normalized_phase),
+ None => binding.base.sample(binding.initial_phase_index, clock_ms),
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct AmbientEffectGeometryUpdate {
+ range_index: usize,
+ index_count: u32,
+ command_rgb: [f32; 3],
}
fn selected_material_phase(
@@ -777,14 +1065,14 @@ fn selected_material_phase(
Ok((material_index, pipeline_category))
}
-#[derive(Clone, Copy, Debug, PartialEq)]
+#[derive(Clone, Copy, Debug)]
struct MaterialRangeSelection {
range_index: usize,
material_index: usize,
pipeline_category: u8,
}
-#[derive(Clone, Copy, Debug, PartialEq)]
+#[derive(Clone, Copy, Debug)]
struct MaterialPropertiesUpdate {
material_index: usize,
diffuse_alpha: f32,
@@ -800,14 +1088,14 @@ struct MaterialPropertiesUpdate {
uv_transforms: [[f32; 4]; 4],
}
-#[derive(Clone, Copy, Debug, PartialEq)]
+#[derive(Clone, Copy, Debug)]
struct MaterialStagePhaseUpdate {
binding_index: usize,
material_index: usize,
phases: [usize; 3],
}
-#[derive(Clone, Debug, Default, PartialEq)]
+#[derive(Clone, Debug, Default)]
struct MaterialFrameUpdate {
active_materials: HashMap<usize, ActiveMaterialState>,
range_selections: Vec<MaterialRangeSelection>,
@@ -822,7 +1110,11 @@ fn sample_material_animations(
) -> Result<MaterialFrameUpdate, String> {
let mut update = MaterialFrameUpdate::default();
for (binding_index, binding) in bindings.iter_mut().enumerate() {
- let base_sample = binding.base.sample(binding.initial_phase_index, clock_ms)?;
+ let base_sample = sample_material_animation_base(binding, clock_ms)?;
+ let material_alpha = ambient_effect_material_alpha(
+ binding.ambient_fx_material_alpha,
+ base_sample.coefficients.opacity,
+ );
let detail_sample = binding
.detail
.as_mut()
@@ -839,7 +1131,7 @@ fn sample_material_animations(
.map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms))
.transpose()?;
let mut updated_materials = HashSet::new();
- for variant in &binding.variants {
+ for variant in &mut binding.variants {
let (material_index, pipeline_category) =
selected_material_phase(variant, base_sample.phase_index, |index| {
materials
@@ -851,7 +1143,9 @@ fn sample_material_animations(
range_index,
ActiveMaterialState {
material_index,
- diffuse_alpha: base_sample.coefficients.opacity,
+ phase_index: base_sample.phase_index,
+ diffuse_alpha: material_alpha,
+ additive_rgb: base_sample.coefficients.additive_rgb,
},
);
update.range_selections.push(MaterialRangeSelection {
@@ -860,6 +1154,7 @@ fn sample_material_animations(
pipeline_category,
});
}
+ variant.pipeline_category = pipeline_category;
if !updated_materials.insert(material_index) {
continue;
}
@@ -871,9 +1166,7 @@ fn sample_material_animations(
let (base_directional, base_additive, overlay_directional, overlay_additive) =
if material.lightmap_mode {
// Terrain mode 3 samples the lightmap in the secondary
- // stage and uses the authored directional RGB as its
- // multiplicative lightmap coefficient. It has no normal
- // lighting or overlay stage.
+ // stage and uses the authored directional RGB as additive.
([0.0; 3], base_coefficients.directional_rgb, None, None)
} else {
(
@@ -883,16 +1176,27 @@ fn sample_material_animations(
overlay_coefficients.map(|coefficients| coefficients.additive_rgb),
)
};
- if !material.lightmap_mode && binding.stage_textures.is_some() {
- update.stage_phases.push(MaterialStagePhaseUpdate {
- binding_index,
- material_index,
- phases: [
+
+ if !material.lightmap_mode {
+ if let Some(stage_textures) = binding.stage_textures.as_ref() {
+ let stage_phases = [
detail_sample.map_or(0, |sample| sample.phase_index),
overlay_sample.map_or(0, |sample| sample.phase_index),
overlay_detail_sample.map_or(0, |sample| sample.phase_index),
- ],
- });
+ ];
+ if stage_textures
+ .applied_material_phases
+ .get(&material_index)
+ .copied()
+ != Some(stage_phases)
+ {
+ update.stage_phases.push(MaterialStagePhaseUpdate {
+ binding_index,
+ material_index,
+ phases: stage_phases,
+ });
+ }
+ }
}
let overlay_uv = overlay_sample
.map(|sample| {
@@ -942,7 +1246,7 @@ fn sample_material_animations(
.checked_page_uv_transform(base_sample.phase_index)?;
update.properties.push(MaterialPropertiesUpdate {
material_index,
- diffuse_alpha: base_coefficients.opacity.clamp(0.0, 1.0),
+ diffuse_alpha: material_alpha.clamp(0.0, 1.0),
overlay_alpha: overlay_coefficients
.map_or(0.0, |coefficients| coefficients.opacity.clamp(0.0, 1.0)),
base_directional,
@@ -1053,11 +1357,29 @@ fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) -
diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0
}
-fn native_msh_diffuse_alpha_override(batch_flags: u16, type16_present: bool) -> Option<f32> {
- if type16_present || batch_flags & 0x0008 == 0 {
- return None;
+fn native_msh_diffuse_alpha_override(
+ mesh: &VulkanStaticMesh,
+ range_index: usize,
+ type16_present: bool,
+ eye_world: [f32; 3],
+ horizontal_fov: f32,
+) -> Result<Option<f32>, String> {
+ let range = mesh
+ .draw_ranges
+ .get(range_index)
+ .ok_or_else(|| "portal draw range is out of bounds".to_string())?;
+ if type16_present || range.batch_flags & 0x0008 == 0 {
+ return Ok(None);
}
- (batch_flags & 0x0040 != 0 && batch_flags & 0x0010 == 0).then_some(0.0)
+ let distance = if range.batch_flags & 0x0050 == 0x0040 {
+ 0.0
+ } else {
+ // shortcut: use the first indexed vertex; replace with native CPU clipping selection when clipped-portal captures are available.
+ first_indexed_vertex_distance(mesh, range_index, eye_world)?
+ };
+ legacy_msh_portal_alpha(range.batch_flags, distance, horizontal_fov)
+ .map(Some)
+ .ok_or_else(|| "portal distance or field of view is invalid".to_string())
}
fn native_msh_handedness(transform: LegacyIron3dEulerTransform, scale: [f32; 3]) -> i8 {
@@ -1370,6 +1692,15 @@ fn world_pipeline_state(blend: LegacyBlendMode, depth: LegacyDepthMode) -> Legac
}
}
+fn projected_shadow_pipeline_state() -> LegacyPipelineState {
+ LegacyPipelineState {
+ blend: LegacyBlendMode::SourceAlpha,
+ depth: LegacyDepthMode::TestReadOnly,
+ depth_bias: LegacyDepthBias::ProjectedDecal,
+ ..LegacyPipelineState::default()
+ }
+}
+
fn environment_vertex(position: [f32; 3], color: [f32; 3], uv: [f32; 2]) -> VulkanStaticVertex {
environment_vertex_with_alpha(position, color, 1.0, uv)
}
@@ -1511,9 +1842,12 @@ fn append_environment_material(
let document = asset.document.clone();
material_animations.push(MaterialAnimationBinding {
initial_phase_index: 0,
+ normalized_phase_override: None,
+ ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices,
range_indices: Vec::new(),
+ pipeline_category: phases[0].pipeline_category,
}],
base: MaterialPhaseBinding {
document,
@@ -1532,6 +1866,132 @@ fn append_environment_material(
Ok(first_material_index)
}
+fn prepare_effect_material(
+ root: &Path,
+ archive: &str,
+ name: &str,
+ materials: &mut Vec<VulkanStaticMaterial>,
+ cache: &mut HashMap<String, PreparedEffectMaterial>,
+) -> Result<PreparedEffectMaterial, String> {
+ let key = format!("{archive}/{name}");
+ if let Some(prepared) = cache.get(&key) {
+ return Ok(prepared.clone());
+ }
+ let (first_mips, first_phase, document) =
+ load_material_archive_name_texture_rgba8_and_phase_with_document_from_root(
+ root, archive, name, 0,
+ )
+ .map_err(|error| format!("resolve FX MAT0 {key}: {error}"))?;
+ if document.phases.is_empty() {
+ return Err(format!("FX MAT0 {key} has no phases"));
+ }
+
+ let mut phase_material_indices = Vec::with_capacity(document.phases.len());
+ let mut phase_uv_transforms = Vec::with_capacity(document.phases.len());
+ let mut phase_native_flag_40 = Vec::with_capacity(document.phases.len());
+ let mut phase_directional_rgb = Vec::with_capacity(document.phases.len());
+ let mut phase_additive_rgb = Vec::with_capacity(document.phases.len());
+ for phase_index in 0..document.phases.len() {
+ let (mips, phase) = if phase_index == 0 {
+ (first_mips.clone(), first_phase)
+ } else {
+ let phase_index_u16 = u16::try_from(phase_index)
+ .map_err(|_| format!("FX MAT0 {key} phase index exceeds u16"))?;
+ let (mips, phase, _document) =
+ load_material_archive_name_texture_rgba8_and_phase_with_document_from_root(
+ root,
+ archive,
+ name,
+ phase_index_u16,
+ )
+ .map_err(|error| format!("resolve FX MAT0 {key} phase {phase_index}: {error}"))?;
+ (mips, phase)
+ };
+ let texture = vulkan_texture_from_rgba_mips(mips, "FX material")?;
+ let index = materials.len();
+ let material_index = u16::try_from(index).map_err(|_| {
+ "FX effects exceed the available 16-bit material selector space".to_string()
+ })?;
+ materials.push(VulkanStaticMaterial {
+ material_index,
+ pipeline_category: phase.pipeline_category,
+ texture,
+ directional_rgb: phase.directional_rgb,
+ additive_rgb: phase.additive_rgb,
+ uv_transform: phase.page_uv_transform,
+ detail_texture: Some(solid_environment_texture([128, 128, 128, 255])),
+ overlay_texture: None,
+ overlay_detail_texture: None,
+ // FX descriptor opacity is carried by each frame's vertex alpha.
+ // Keep MAT0 alpha at one so it does not multiply the opacity a
+ // second time.
+ diffuse_alpha: 1.0,
+ overlay_diffuse_alpha: None,
+ overlay_directional_rgb: None,
+ overlay_additive_rgb: None,
+ specular_rgb: phase.specular_rgb,
+ specular_power: phase.power,
+ overlay_specular_rgb: None,
+ overlay_specular_power: None,
+ detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
+ overlay_uv_transform: None,
+ overlay_detail_uv_transform: None,
+ unlit: true,
+ sky_nebula_stars: false,
+ lightmap_mode: false,
+ sky_far_depth: false,
+ screen_tl: false,
+ });
+ phase_material_indices.push(index);
+ phase_uv_transforms.push(phase.page_uv_transform);
+ phase_native_flag_40.push(phase.native_flag_40);
+ phase_directional_rgb.push(phase.directional_rgb);
+ phase_additive_rgb.push(phase.additive_rgb);
+ }
+ let prepared = PreparedEffectMaterial {
+ initial_pipeline_category: materials[phase_material_indices[0]].pipeline_category,
+ document,
+ phase_material_indices,
+ phase_uv_transforms,
+ phase_native_flag_40,
+ phase_directional_rgb,
+ phase_additive_rgb,
+ };
+ cache.insert(key, prepared.clone());
+ Ok(prepared)
+}
+
+fn append_effect_material_animation(
+ prepared: &PreparedEffectMaterial,
+ range_index: usize,
+ bindings: &mut Vec<MaterialAnimationBinding>,
+) -> usize {
+ let binding_index = bindings.len();
+ bindings.push(MaterialAnimationBinding {
+ initial_phase_index: 0,
+ normalized_phase_override: None,
+ ambient_fx_material_alpha: true,
+ variants: vec![MaterialVariantSet {
+ phase_material_indices: prepared.phase_material_indices.clone(),
+ range_indices: vec![range_index],
+ pipeline_category: prepared.initial_pipeline_category,
+ }],
+ base: MaterialPhaseBinding {
+ document: prepared.document.clone(),
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: u32::try_from(binding_index).unwrap_or(u32::MAX),
+ frozen_phase_index: None,
+ phase_uv_transforms: prepared.phase_uv_transforms.clone(),
+ },
+ detail: None,
+ overlay: None,
+ overlay_detail: None,
+ stage_textures: None,
+ });
+ binding_index
+}
+
fn append_environment_gradient_material(
materials: &mut Vec<VulkanStaticMaterial>,
) -> Result<usize, String> {
@@ -1648,53 +2108,58 @@ fn terrain_shadow_receivers(
) -> Result<Vec<ShadowTriangle>, String> {
let assignments = terrain
.face_lod_assignments()
- .map_err(|error| error.to_string())?;
- let mut receivers = Vec::with_capacity(
- assignments
- .iter()
- .filter(|assignment| assignment.lod == TerrainLod::Lod0)
- .count(),
- );
- for assignment in assignments {
- if assignment.lod != TerrainLod::Lod0 {
- continue;
- }
- let face = terrain.faces.get(assignment.face_index).ok_or_else(|| {
- format!(
- "terrain LOD0 references unknown face {}",
- assignment.face_index
- )
- })?;
- let positions = [
- terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[0])?,
- terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[1])?,
- terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[2])?,
- ];
- receivers.push(shadow_triangle(positions, face.flags.0));
- }
- Ok(receivers)
+ .map_err(|error| format!("select terrain LOD0 shadow receivers: {error}"))?;
+ assignments
+ .into_iter()
+ .filter(|assignment| assignment.lod == TerrainLod::Lod0)
+ .map(|assignment| {
+ let face = terrain.faces.get(assignment.face_index).ok_or_else(|| {
+ format!(
+ "terrain LOD assignment references missing face {}",
+ assignment.face_index
+ )
+ })?;
+ let positions = source_terrain_face_positions(terrain, assignment.face_index)?;
+ if !positions.iter().flatten().all(|value| value.is_finite()) {
+ return Err("terrain shadow receiver contains a non-finite position".to_string());
+ }
+ Ok(shadow_triangle(positions, face.flags.0))
+ })
+ .collect()
}
-fn terrain_shadow_vertex_position(
+fn source_terrain_face_positions(
terrain: &fparkan_terrain_format::LandMeshDocument,
face_index: usize,
- vertex_index: u16,
-) -> Result<[f32; 3], String> {
- let position = terrain
- .positions
- .get(usize::from(vertex_index))
- .copied()
- .ok_or_else(|| {
- format!(
- "terrain LOD0 shadow face {face_index} references unknown vertex {vertex_index}"
- )
- })?;
- if !position.iter().all(|value| value.is_finite()) {
- return Err(format!(
- "terrain LOD0 shadow face {face_index} has a non-finite vertex {vertex_index}"
- ));
- }
- Ok(position)
+) -> Result<[[f32; 3]; 3], String> {
+ let face = terrain
+ .faces
+ .get(face_index)
+ .ok_or_else(|| format!("terrain LOD assignment references missing face {face_index}"))?;
+ let [first, second, third] = face.vertices;
+ Ok([
+ terrain
+ .positions
+ .get(usize::from(first))
+ .copied()
+ .ok_or_else(|| {
+ format!("terrain face {face_index} references missing vertex {first}")
+ })?,
+ terrain
+ .positions
+ .get(usize::from(second))
+ .copied()
+ .ok_or_else(|| {
+ format!("terrain face {face_index} references missing vertex {second}")
+ })?,
+ terrain
+ .positions
+ .get(usize::from(third))
+ .copied()
+ .ok_or_else(|| {
+ format!("terrain face {face_index} references missing vertex {third}")
+ })?,
+ ])
}
fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle> {
@@ -1721,46 +2186,205 @@ fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle>
.collect()
}
-fn expand_shadow_sphere_to_triangles(
- sphere: &mut ShadowSphere,
+fn static_mesh_shadow_triangles_in_index_range(
+ mesh: &VulkanStaticMesh,
+ first_index: usize,
+ index_count: usize,
+) -> Result<Vec<ShadowTriangle>, String> {
+ if index_count % 3 != 0 {
+ return Err(format!(
+ "dynamic shadow index range {first_index}..{} is not triangle-aligned",
+ first_index.saturating_add(index_count)
+ ));
+ }
+ let end = first_index
+ .checked_add(index_count)
+ .filter(|end| *end <= mesh.indices.len())
+ .ok_or_else(|| {
+ format!(
+ "dynamic shadow index range {first_index}..{} is out of bounds",
+ first_index.saturating_add(index_count)
+ )
+ })?;
+ mesh.indices[first_index..end]
+ .chunks_exact(3)
+ .map(|triangle| {
+ let positions =
+ triangle
+ .iter()
+ .map(|index| {
+ mesh.vertices
+ .get(usize::try_from(*index).map_err(|_| {
+ "dynamic shadow vertex index exceeds usize".to_string()
+ })?)
+ .map(|vertex| vertex.position)
+ .ok_or_else(|| {
+ format!("dynamic shadow vertex index {index} is out of bounds")
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ let positions: [[f32; 3]; 3] = positions
+ .try_into()
+ .map_err(|_| "dynamic shadow triangle did not have three vertices".to_string())?;
+ if !positions.iter().flatten().all(|value| value.is_finite()) {
+ return Err("dynamic shadow triangle contains a non-finite position".to_string());
+ }
+ Ok(shadow_triangle(positions, 0))
+ })
+ .collect()
+}
+
+fn refresh_dynamic_shadow_casters(
+ casters: &mut [ShadowCaster],
+ dynamic_casters: &[DynamicShadowCaster],
+ mesh: &VulkanStaticMesh,
+ pose_buffers: &[Vec<Option<NodePoseBuffer>>],
+ mount_poses: &[Vec<Option<VulkanNodePose>>],
+) -> Result<(), String> {
+ for dynamic in dynamic_casters {
+ let caster = casters.get_mut(dynamic.caster_index).ok_or_else(|| {
+ format!(
+ "dynamic shadow caster {} is outside the aggregate caster table",
+ dynamic.caster_index
+ )
+ })?;
+ let mut triangles = Vec::new();
+ for &range_index in &dynamic.component_draw_range_indices {
+ let range = mesh.draw_ranges.get(range_index).ok_or_else(|| {
+ format!(
+ "dynamic shadow caster {} references missing draw range {range_index}",
+ dynamic.caster_index
+ )
+ })?;
+ triangles.extend(static_mesh_shadow_triangles_in_index_range(
+ mesh,
+ usize::try_from(range.first_index)
+ .map_err(|_| "dynamic shadow draw range index exceeds usize".to_string())?,
+ usize::try_from(range.index_count)
+ .map_err(|_| "dynamic shadow draw range count exceeds usize".to_string())?,
+ )?);
+ }
+ if triangles.is_empty() {
+ return Err(format!(
+ "dynamic shadow caster {} lost its component triangles",
+ dynamic.caster_index
+ ));
+ }
+ let component_spheres = dynamic
+ .component_spheres
+ .iter()
+ .filter_map(|source| {
+ let mount_pose = if source.mounted {
+ mount_poses
+ .get(dynamic.object_index)
+ .and_then(|items| items.get(source.component_index))
+ .copied()
+ .flatten()
+ } else {
+ None
+ };
+ if source.mounted && mount_pose.is_none() {
+ return None;
+ }
+ let node1_pose = if source.mounted {
+ pose_buffers
+ .get(dynamic.object_index)
+ .and_then(|items| items.get(source.component_index))
+ .and_then(Option::as_ref)
+ .and_then(|hierarchy| {
+ node38_pose_relative_to_root_from_hierarchy(hierarchy, 1)
+ })
+ } else {
+ None
+ };
+ mounted_shadow_sphere(source.center, source.radius, mount_pose, node1_pose)
+ })
+ .collect::<Vec<_>>();
+ if component_spheres.len() != dynamic.component_spheres.len() {
+ return Err(format!(
+ "dynamic shadow caster {} lost one or more component sphere poses",
+ dynamic.caster_index
+ ));
+ }
+ let mut refreshed = aggregate_shadow_caster(
+ caster.id,
+ caster.native_kind,
+ dynamic.root_transform,
+ dynamic.owner_scale,
+ triangles,
+ &component_spheres,
+ )
+ .ok_or_else(|| {
+ format!(
+ "dynamic shadow caster {} could not be rebuilt from current component poses",
+ dynamic.caster_index
+ )
+ })?;
+ // shortcut: animated native sphere parity is unverified. Rebuild from
+ // the startup native bound, the current native bound, and current
+ // triangles so the result is conservative and independent of history.
+ refreshed.sphere = shadow_sphere_enclosing_current_geometry(
+ dynamic.initial_sphere,
+ refreshed.sphere,
+ &refreshed.triangles,
+ )?;
+ *caster = refreshed;
+ }
+ Ok(())
+}
+
+fn shadow_sphere_enclosing_current_geometry(
+ baseline: ShadowSphere,
+ current_native: ShadowSphere,
triangles: &[ShadowTriangle],
-) -> Result<bool, String> {
+) -> Result<ShadowSphere, String> {
+ let mut sphere = baseline;
if !sphere.radius.is_finite()
|| sphere.radius < 0.0
|| !sphere.center.iter().all(|value| value.is_finite())
+ || !current_native.radius.is_finite()
+ || current_native.radius < 0.0
+ || !current_native.center.iter().all(|value| value.is_finite())
{
return Err("animated shadow caster has an invalid bounding sphere".to_string());
}
- let mut changed = false;
+
+ let offset = sub3(current_native.center, sphere.center);
+ let distance = offset[0].hypot(offset[1]).hypot(offset[2]);
+ if !distance.is_finite() {
+ return Err("animated shadow sphere distance is non-finite".to_string());
+ }
+ let required = distance + current_native.radius;
+ if !required.is_finite() {
+ return Err("animated shadow sphere radius is non-finite".to_string());
+ }
+ if current_native.radius >= distance + sphere.radius {
+ sphere = current_native;
+ } else if required > sphere.radius {
+ let next_radius = sphere.radius + 0.5 * (required - sphere.radius);
+ let shift = scale3(offset, (next_radius - sphere.radius) / distance);
+ sphere.center = add3(sphere.center, shift);
+ sphere.radius = next_radius;
+ }
+
for point in triangles.iter().flat_map(|triangle| triangle.positions) {
if !point.iter().all(|value| value.is_finite()) {
return Err("animated shadow triangle has a non-finite position".to_string());
}
let offset = sub3(point, sphere.center);
- let distance = length3(offset)
- .ok_or_else(|| "animated shadow point distance is non-finite".to_string())?;
+ let distance = offset[0].hypot(offset[1]).hypot(offset[2]);
+ if !distance.is_finite() {
+ return Err("animated shadow point distance is non-finite".to_string());
+ }
if distance <= sphere.radius {
continue;
}
- let next_radius = 0.5 * (sphere.radius + distance);
- let center_shift = if distance > f32::EPSILON {
- scale3(offset, (next_radius - sphere.radius) / distance)
- } else {
- [0.0; 3]
- };
+ let next_radius = sphere.radius + 0.5 * (distance - sphere.radius);
+ let center_shift = scale3(offset, (next_radius - sphere.radius) / distance);
sphere.center = add3(sphere.center, center_shift);
sphere.radius = next_radius;
- changed = true;
}
- Ok(changed)
-}
-fn shadow_sphere_enclosing_triangles(
- baseline: ShadowSphere,
- triangles: &[ShadowTriangle],
-) -> Result<ShadowSphere, String> {
- let mut sphere = baseline;
- expand_shadow_sphere_to_triangles(&mut sphere, triangles)?;
Ok(sphere)
}
@@ -1870,6 +2494,7 @@ fn shadow_lights(
sky_frame: Option<&SkyFrame<'_>>,
environment_frame: Option<&EnvironmentFrame>,
renderer: &VulkanSmokeRenderer,
+ control_point_lights: &[(VulkanPointLight, u32)],
) -> Vec<ShadowLight> {
let mut lights = Vec::new();
if let Some(sky_frame) = sky_frame {
@@ -1931,6 +2556,18 @@ fn shadow_lights(
flags: 0,
});
}
+ lights.extend(
+ control_point_lights
+ .iter()
+ .map(|(light, flags)| ShadowLight::Point {
+ position: light.position,
+ rgb: light.rgb,
+ range: light.range,
+ coefficients: light.coefficients,
+ active: light.active,
+ flags: *flags,
+ }),
+ );
lights
}
@@ -2184,6 +2821,33 @@ fn reorder_draw_ranges(
Ok(remap)
}
+fn remap_dynamic_shadow_caster_draw_ranges(
+ dynamic_casters: &mut [DynamicShadowCaster],
+ range_remap: &[usize],
+) -> Result<(), String> {
+ let remapped = dynamic_casters
+ .iter()
+ .map(|dynamic| {
+ dynamic
+ .component_draw_range_indices
+ .iter()
+ .map(|&range_index| {
+ range_remap.get(range_index).copied().ok_or_else(|| {
+ format!(
+ "dynamic shadow caster {} references missing pre-reorder draw range {range_index}",
+ dynamic.caster_index
+ )
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ for (dynamic, ranges) in dynamic_casters.iter_mut().zip(remapped) {
+ dynamic.component_draw_range_indices = ranges;
+ }
+ Ok(())
+}
+
fn quad_indices(slot_count: usize) -> Result<Vec<u32>, String> {
let mut indices = Vec::with_capacity(
slot_count
@@ -2283,6 +2947,8 @@ impl EnvironmentGpuScene {
type16_present_ranges: &mut HashSet<usize>,
materials: &mut Vec<VulkanStaticMaterial>,
preview_material_animations: &mut Vec<MaterialAnimationBinding>,
+ ambient_effect_slots: &mut [AmbientEffectGpuSlot],
+ dynamic_shadow_casters: &mut [DynamicShadowCaster],
sun_occlusion_range_indices: &mut Vec<usize>,
environment_materials: &SkyMaterials,
material_assets: &HashMap<String, EnvironmentMaterialAsset>,
@@ -2717,6 +3383,7 @@ impl EnvironmentGpuScene {
remapped_type16_present_ranges.insert(new_range_index);
}
*type16_present_ranges = remapped_type16_present_ranges;
+ remap_dynamic_shadow_caster_draw_ranges(dynamic_shadow_casters, &remap)?;
for layer in &mut scene.sky_layers {
layer.range.range_index = *remap
.get(layer.range.range_index)
@@ -2738,6 +3405,16 @@ impl EnvironmentGpuScene {
.ok_or_else(|| "lightning draw range remap is out of bounds".to_string())?;
remap_material_animation_ranges(&mut scene.material_animations, &remap)?;
remap_material_animation_ranges(preview_material_animations, &remap)?;
+ for slot in ambient_effect_slots {
+ slot.range.range_index = *remap
+ .get(slot.range.range_index)
+ .ok_or_else(|| "ambient FX draw range remap is out of bounds".to_string())?;
+ for range in &mut slot.additional_particle_ranges {
+ range.range_index = *remap.get(range.range_index).ok_or_else(|| {
+ "ambient FX particle draw range remap is out of bounds".to_string()
+ })?;
+ }
+ }
for range_index in sun_occlusion_range_indices {
*range_index = *remap
.get(*range_index)
@@ -3278,8 +3955,8 @@ fn segment_intersects_triangle(
distance.is_finite() && (0.0..=1.0).contains(&distance)
}
-/// Replays the native draw-item callback: one whole indexed batch is keyed by
-/// the camera distance to its first indexed vertex.
+/// Replays Terrain's native packet callback: a draw range is keyed by the
+/// camera distance to its first indexed vertex.
fn first_indexed_vertex_distance(
mesh: &VulkanStaticMesh,
range_index: usize,
@@ -3508,9 +4185,21 @@ 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,
+ fixed_sky_uv_ms: Option<u32>,
+}
+
struct DynamicEnvironmentFrame {
- atmosphere: fparkan_fx::atmosphere::AtmosphereFrame,
+ atmosphere: AtmosphereFrame,
sky_mesh: SkyMesh,
environment_frame: EnvironmentFrame,
absolute_time_seconds: f32,
@@ -3532,26 +4221,29 @@ 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,
+ clock_ms: u32,
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() {
@@ -3576,6 +4268,7 @@ impl DynamicEnvironment {
}
let atmosphere = self.schedule.sample(absolute_time_seconds);
let mut sky_mesh = self.sky.mesh().clone();
+ sky_mesh.update_uvs(self.fixed_sky_uv_ms.unwrap_or(clock_ms));
let sky_frame =
SkyFrame::from_atmosphere(&atmosphere, &self.materials, absolute_time_seconds);
sky_mesh.update_colors(&sky_frame.sky);
@@ -3592,31 +4285,6 @@ impl DynamicEnvironment {
.map_err(|error| format!("update ambient variation: {error}"))?;
}
- let mut uniforms = renderer.frame_uniforms();
- uniforms.clip_from_world = camera.clip_from_world;
- for (target, source) in uniforms
- .directional_lights
- .iter_mut()
- .zip(sky_frame.directional_lights)
- {
- target.direction = source.direction;
- target.rgb = source.color;
- target.coefficients = [0.0, 0.0, 1.0];
- target.active = source.active;
- }
- uniforms.lighting_floor = sky_frame.lighting_floor;
- let fog_rgb = sky_frame
- .screen_gradient(camera.native_sky_heading(), [0.0; 3])
- .color
- .rgba();
- uniforms.fog_color = [fog_rgb[0], fog_rgb[1], fog_rgb[2]];
- uniforms.fog_start = sky_frame.fog.start.max(0.0);
- uniforms.fog_end = sky_frame.fog.end.max(uniforms.fog_start);
- uniforms.camera_position = camera.position;
- renderer
- .set_frame_uniforms(uniforms)
- .map_err(|error| format!("update sky frame uniforms: {error}"))?;
-
let fx_camera = environment_camera_for_preview(camera, aspect, viewport);
let environment_frame = self.environment.update_atmosphere_with_materials(
dt_seconds,
@@ -3624,22 +4292,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
@@ -3823,6 +4475,7 @@ fn load_environment(
time_seconds,
fixed_schedule_seconds,
fixed_phase_reported: false,
+ fixed_sky_uv_ms: None,
}))
}
@@ -3942,6 +4595,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]))
}
@@ -3981,23 +4638,27 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
struct StaticPreviewScene {
mesh: VulkanStaticMesh,
+ dynamic_vertex_start: usize,
+ terrain_lod: Option<terrain_lod::TerrainLodDraws>,
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<ControlEffectSlot>,
+ control_sound_slots: Vec<ControlSoundSlot>,
+ unsupported_control_sounds: usize,
+ component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
+ component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
+ dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>,
+ building_collision_bindings: Vec<BuildingCollisionBinding>,
+ control_animation_instances: Vec<Vec<Option<ControlAnimationInstance>>>,
+ dynamic_shadow_casters: Vec<DynamicShadowCaster>,
sun_occlusion_range_indices: Vec<usize>,
shadow_casters: Vec<ShadowCaster>,
- shadow_sphere_baselines: Vec<ShadowSphere>,
shadow_receivers: Vec<ShadowTriangle>,
shadow_material_index: usize,
terrain: Arc<TerrainWorld>,
- component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
- component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
- control_animation_instances: Vec<ControlAnimationInstance>,
- dynamic_component_geometry: Vec<DynamicComponentGeometry>,
- building_collision_bindings: Vec<BuildingCollisionBinding>,
- dynamic_component_mounts: Vec<DynamicComponentMount>,
- shadow_caster_indices_by_object: Vec<Option<usize>>,
camera: VulkanStaticCamera,
view_camera: PreviewCameraFrame,
captured_viewport: Option<CapturedViewport>,
@@ -4007,25 +4668,24 @@ struct StaticPreviewScene {
terrain_components: usize,
}
-#[derive(Clone, Debug)]
+#[derive(Clone)]
struct ControlAnimationInstance {
- object_index: usize,
- component_index: usize,
- model: Arc<ModelAsset>,
control: PreparedComponentControl,
+ model: ModelAsset,
+ gate_values: Vec<Option<f32>>,
state: ControlInstanceState,
+ last_sampled_node_times: Vec<Option<f32>>,
+ reported_unsupported_fallback: bool,
}
-#[derive(Clone, Debug)]
+#[derive(Clone)]
struct DynamicComponentGeometry {
- object_index: usize,
- component_index: usize,
- model: Arc<ModelAsset>,
- transform: LegacyIron3dEulerTransform,
- scale: [f32; 3],
+ model: ModelAsset,
+ root_transform: LegacyIron3dEulerTransform,
+ owner_scale: [f32; 3],
+ parent_mount: Option<(usize, usize)>,
vertex_start: usize,
vertex_count: usize,
- shadow_triangle_range: Option<std::ops::Range<usize>>,
}
#[derive(Clone)]
@@ -4230,12 +4890,23 @@ fn component_node_world_matrix(
world.iter().all(|value| value.is_finite()).then_some(world)
}
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-struct DynamicComponentMount {
+#[derive(Clone, Debug)]
+struct DynamicShadowCaster {
+ caster_index: usize,
object_index: usize,
+ root_transform: LegacyIron3dEulerTransform,
+ owner_scale: [f32; 3],
+ initial_sphere: ShadowSphere,
+ component_draw_range_indices: Vec<usize>,
+ component_spheres: Vec<DynamicShadowSphereSource>,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct DynamicShadowSphereSource {
component_index: usize,
- parent_index: usize,
- socket: usize,
+ center: [f32; 3],
+ radius: f32,
+ mounted: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -4326,6 +4997,1965 @@ 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_effect_world_from_component_pose_with_random_shift(
+ slot: &ControlEffectSlot,
+ 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 [ControlEffectSlot],
+ 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_effect_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<ControlEffectSlot>,
+ sound_slots: &mut Vec<ControlSoundSlot>,
+ unsupported_sounds: &mut usize,
+) -> 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 sound_commands = document
+ .commands()
+ .iter()
+ .filter(|command| command.opcode == FxOpcode::Op2)
+ .count();
+ 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.
+ *unsupported_sounds += sound_commands;
+ 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 {
+ *unsupported_sounds += sound_commands;
+ 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 {
+ *unsupported_sounds += sound_commands;
+ 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) || command.opcode == FxOpcode::Op2 {
+ let slot = ControlEffectSlot {
+ 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(),
+ };
+ if command.opcode == FxOpcode::Op2 {
+ if let Some(sound) = Op2SoundCommand::from_command(command) {
+ sound_slots.push(ControlSoundSlot {
+ effect: slot,
+ command: sound,
+ state: Op2SoundState::default(),
+ });
+ } else {
+ *unsupported_sounds += 1;
+ eprintln!("unsupported Control Op2 object={object_index} component={component_index} effect={key} command={command_index} (mode, backend parameter or malformed body)");
+ }
+ } else {
+ point_light_slots.push(slot);
+ }
+ 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: &[ControlEffectSlot],
+ sound_slots: &[ControlSoundSlot],
+ 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
+ .iter()
+ .chain(sound_slots.iter().map(|slot| &slot.effect))
+ {
+ let initial_phase = ambient_effect_phase(
+ slot.time_mode,
+ slot.header_flags,
+ elapsed_seconds,
+ slot.duration_seconds,
+ )
+ .map(|phase| initial_native_effect_phase(slot.header_flags, phase))
+ .unwrap_or(-1.0);
+ specifications.entry(slot.random_instance_key).or_insert((
+ slot.random_seed,
+ slot.header_flags,
+ slot.phase_jitter,
+ slot.random_shift,
+ initial_phase,
+ ));
+ }
+
+ specifications
+ .into_iter()
+ .map(
+ |(key, (seed, flags, phase_jitter, random_shift, initial_phase))| {
+ let state = states.entry(key).or_insert_with(|| {
+ ControlEffectRandomState::new(seed, flags, phase_jitter, random_shift)
+ });
+ (
+ key,
+ state.advance(sample_id, control_clock_ms, initial_phase),
+ )
+ },
+ )
+ .collect()
+}
+
+fn ambient_effect_ranges(slot: &AmbientEffectGpuSlot) -> Vec<(&EnvironmentGpuRange, usize)> {
+ std::iter::once((&slot.range, slot.material_animation_index))
+ .chain(
+ slot.additional_particle_ranges
+ .iter()
+ .zip(&slot.additional_particle_material_animation_indices)
+ .map(|(range, &binding)| (range, binding)),
+ )
+ .collect()
+}
+
+fn ambient_effect_quad_center(vertices: &[[f32; 3]; 4]) -> [f32; 3] {
+ std::array::from_fn(|axis| vertices.iter().map(|vertex| vertex[axis]).sum::<f32>() / 4.0)
+}
+
+fn ambient_effect_quad_size(vertices: &[[f32; 3]; 4]) -> [f32; 2] {
+ let edge_length = |a: [f32; 3], b: [f32; 3]| {
+ ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
+ };
+ [
+ edge_length(vertices[0], vertices[1]),
+ edge_length(vertices[1], vertices[2]),
+ ]
+}
+
+fn ambient_effect_projected_bounds(
+ vertices: &[[f32; 3]; 4],
+ clip_from_world: [f32; 16],
+ viewport: [u32; 2],
+) -> Option<[f32; 4]> {
+ let mut points = [[0.0; 2]; 4];
+ for (index, vertex) in vertices.iter().enumerate() {
+ let clip = [
+ vertex[0] * clip_from_world[0]
+ + vertex[1] * clip_from_world[4]
+ + vertex[2] * clip_from_world[8]
+ + clip_from_world[12],
+ vertex[0] * clip_from_world[1]
+ + vertex[1] * clip_from_world[5]
+ + vertex[2] * clip_from_world[9]
+ + clip_from_world[13],
+ vertex[0] * clip_from_world[2]
+ + vertex[1] * clip_from_world[6]
+ + vertex[2] * clip_from_world[10]
+ + clip_from_world[14],
+ vertex[0] * clip_from_world[3]
+ + vertex[1] * clip_from_world[7]
+ + vertex[2] * clip_from_world[11]
+ + clip_from_world[15],
+ ];
+ if !clip.iter().all(|value| value.is_finite()) || clip[3] <= f32::EPSILON {
+ return None;
+ }
+ points[index] = [
+ (clip[0] / clip[3] + 1.0) * 0.5 * viewport[0] as f32,
+ (clip[1] / clip[3] + 1.0) * 0.5 * viewport[1] as f32,
+ ];
+ }
+ Some([
+ points
+ .iter()
+ .map(|point| point[0])
+ .fold(f32::INFINITY, f32::min),
+ points
+ .iter()
+ .map(|point| point[1])
+ .fold(f32::INFINITY, f32::min),
+ points
+ .iter()
+ .map(|point| point[0])
+ .fold(f32::NEG_INFINITY, f32::max),
+ points
+ .iter()
+ .map(|point| point[1])
+ .fold(f32::NEG_INFINITY, f32::max),
+ ])
+}
+
+fn ambient_effect_range_for(
+ slot: &AmbientEffectGpuSlot,
+ range_index: usize,
+) -> Option<&EnvironmentGpuRange> {
+ std::iter::once(&slot.range)
+ .chain(slot.additional_particle_ranges.iter())
+ .find(|range| range.range_index == range_index)
+}
+
+fn ambient_effect_pipeline_state(category: u8) -> Option<LegacyPipelineState> {
+ world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly)
+ .with_material_category(category)
+}
+
+fn ambient_effect_tint_supported(
+ phase_native_flag_40: &[bool],
+ phase_directional_rgb: &[[f32; 3]],
+ phase_additive_rgb: &[[f32; 3]],
+) -> bool {
+ !phase_native_flag_40.is_empty()
+ && phase_native_flag_40.len() == phase_directional_rgb.len()
+ && phase_native_flag_40.len() == phase_additive_rgb.len()
+ && phase_native_flag_40.iter().all(|flag| !flag)
+ && phase_directional_rgb
+ .iter()
+ .zip(phase_additive_rgb)
+ .all(|(directional, additive)| {
+ directional
+ .iter()
+ .chain(additive)
+ .all(|channel| channel.is_finite() && (0.0..=1.0).contains(channel))
+ && (0..3).all(|channel| additive[channel] > 0.0 || directional[channel] == 0.0)
+ })
+}
+
+fn fixed_effect_field(field: &[u8; 32]) -> String {
+ let length = field
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(field.len());
+ String::from_utf8_lossy(&field[..length]).trim().to_owned()
+}
+
+fn update_ambient_effect_geometry(
+ slots: &mut [AmbientEffectGpuSlot],
+ component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
+ mesh: &mut VulkanStaticMesh,
+ material_animations: &mut [MaterialAnimationBinding],
+ elapsed_seconds: f32,
+ random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>,
+ camera_world: [f32; 3],
+) -> Result<Vec<AmbientEffectGeometryUpdate>, String> {
+ let mut updates = Vec::with_capacity(slots.len());
+ for slot in slots {
+ let random_sample = random_samples
+ .get(&slot.random_instance_key)
+ .copied()
+ .unwrap_or_default();
+ let Some(world_from_effect) = ambient_effect_world_from_component_pose_with_random_shift(
+ slot,
+ component_pose_buffers,
+ random_sample.accumulated_shift,
+ ) else {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ };
+ slot.world_from_effect = world_from_effect;
+ let Some(phase) = ambient_effect_phase(
+ slot.time_mode,
+ slot.header_flags,
+ elapsed_seconds,
+ slot.duration_seconds,
+ )
+ .map(|phase| native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)) else {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ };
+ if slot.kind == AmbientEffectKind::GenerBall && slot.command.opcode == FxOpcode::Op9 {
+ let Some(frame) = evaluate_f_gener_ball_mesh(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ phase,
+ elapsed_seconds,
+ camera_world,
+ AMBIENT_EFFECT_NATIVE_CATEGORY,
+ ) else {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ };
+ let primitives = ambient_effect_mesh_primitive_layout(&frame)?;
+ let ranges = ambient_effect_ranges(slot);
+ if ranges.len() != primitives.len() {
+ return Err(format!(
+ "ambient Op9 draw range count changed ({} != {})",
+ ranges.len(),
+ primitives.len()
+ ));
+ }
+ for (primitive, (range, material_animation_index)) in primitives.into_iter().zip(ranges)
+ {
+ let binding = material_animations
+ .get_mut(material_animation_index)
+ .ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?;
+ binding.normalized_phase_override = Some(frame.material_phase);
+ write_ambient_effect_mesh_primitive(mesh, range, &frame, primitive)?;
+ updates.push(AmbientEffectGeometryUpdate {
+ range_index: range.range_index,
+ index_count: if frame.active && frame.color[3] > 0.0 {
+ u32::try_from(primitive.index_count)
+ .map_err(|_| "ambient Op9 index count exceeds u32".to_string())?
+ } else {
+ 0
+ },
+ command_rgb: [frame.color[0], frame.color[1], frame.color[2]],
+ });
+ }
+ continue;
+ }
+ let frames = match (slot.kind, slot.command.opcode) {
+ (AmbientEffectKind::Op3Type0, FxOpcode::Op3) => evaluate_op3_type0_command(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ elapsed_seconds,
+ phase,
+ camera_world,
+ )
+ .into_iter()
+ .collect(),
+ (AmbientEffectKind::Op7ParticlePool, FxOpcode::Op7) => {
+ let Some(state) = slot.op7_emitter_state.as_mut() else {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ };
+ let Some(frames) = evaluate_op7_emitter(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ camera_world,
+ phase,
+ elapsed_seconds,
+ Op7RenderContext {
+ detail_level: 0,
+ // At detail level zero, [0,1) cutoffs with zero bias
+ // are all visible. The native quality preset remains
+ // unresolved, but no particle is culled here.
+ visibility: Op7VisibilityProfile {
+ width: 1.0,
+ bias: 0.0,
+ },
+ // Terrain.dll initializes the native world's world
+ // scalar to 10.0. An engine integration can supply the
+ // live value after it exposes the world setting.
+ world_scalar: 10.0,
+ },
+ state,
+ ) else {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ };
+ fx_material_speed_diagnostic(slot, 0xAC, elapsed_seconds, phase, &frames);
+ frames
+ }
+ (AmbientEffectKind::SmokePool, FxOpcode::Op8) => {
+ let Some(state) = slot.smoke_emitter_state.as_mut() else {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ };
+ let frames = evaluate_op8_emitter(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ camera_world,
+ elapsed_seconds,
+ phase,
+ 0,
+ Op8DetailThresholdRange::new(1.0, 1.0)
+ .expect("ordered finite Op8 material threshold"),
+ state,
+ )
+ .unwrap_or_default();
+ smoke_fr_02_diagnostic(slot, elapsed_seconds, phase, &frames);
+ fx_material_speed_diagnostic(slot, 0xD4, elapsed_seconds, phase, &frames);
+ frames
+ }
+ (AmbientEffectKind::Op3Type1, FxOpcode::Op3) => evaluate_op3_type1_command(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ elapsed_seconds,
+ phase,
+ camera_world,
+ )
+ .into_iter()
+ .collect(),
+ (AmbientEffectKind::Op3Type2, FxOpcode::Op3) => evaluate_op3_type2_command(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ elapsed_seconds,
+ phase,
+ 0,
+ )
+ .into_iter()
+ .collect(),
+ (AmbientEffectKind::GenerBall, FxOpcode::Op4) => evaluate_f_gener_ball(
+ &slot.command,
+ slot.command_index,
+ &slot.world_from_effect,
+ phase,
+ elapsed_seconds,
+ camera_world,
+ )
+ .into_iter()
+ .collect(),
+ _ => Vec::new(),
+ };
+ let ranges = ambient_effect_ranges(slot);
+ if frames.is_empty() {
+ deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
+ continue;
+ }
+ let variable_pool = slot.kind == AmbientEffectKind::Op7ParticlePool;
+ let aligned_frames = align_ambient_effect_frames(frames, ranges.len(), variable_pool)?;
+ for ((range, material_animation_index), frame) in ranges.into_iter().zip(aligned_frames) {
+ if let Some(frame) = frame {
+ let binding = material_animations
+ .get_mut(material_animation_index)
+ .ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?;
+ binding.normalized_phase_override = Some(frame.material_phase);
+ write_ambient_effect_frame(mesh, range, &frame)?;
+ updates.push(AmbientEffectGeometryUpdate {
+ range_index: range.range_index,
+ index_count: if frame.active && frame.color[3] > 0.0 {
+ 6
+ } else {
+ 0
+ },
+ command_rgb: [frame.color[0], frame.color[1], frame.color[2]],
+ });
+ } else {
+ deactivate_ambient_effect_range(
+ range,
+ material_animation_index,
+ material_animations,
+ &mut updates,
+ )?;
+ }
+ }
+ }
+ Ok(updates)
+}
+
+fn align_ambient_effect_frames(
+ frames: Vec<fparkan_fx::fx_visual::EffectQuadFrame>,
+ range_count: usize,
+ variable_pool: bool,
+) -> Result<Vec<Option<fparkan_fx::fx_visual::EffectQuadFrame>>, String> {
+ if (variable_pool && frames.len() > range_count)
+ || (!variable_pool && frames.len() != range_count)
+ {
+ return Err(format!(
+ "ambient FX frame pool has {} particles but {range_count} draw ranges",
+ frames.len()
+ ));
+ }
+ let active_count = frames.len();
+ let mut aligned: Vec<_> = frames.into_iter().map(Some).collect();
+ aligned.extend(std::iter::repeat_with(|| None).take(range_count - active_count));
+ Ok(aligned)
+}
+
+fn deactivate_ambient_effect_slot(
+ slot: &AmbientEffectGpuSlot,
+ material_animations: &mut [MaterialAnimationBinding],
+ updates: &mut Vec<AmbientEffectGeometryUpdate>,
+) -> Result<(), String> {
+ for (range, material_animation_index) in ambient_effect_ranges(slot) {
+ deactivate_ambient_effect_range(
+ range,
+ material_animation_index,
+ material_animations,
+ updates,
+ )?;
+ }
+ Ok(())
+}
+
+fn deactivate_ambient_effect_range(
+ range: &EnvironmentGpuRange,
+ material_animation_index: usize,
+ material_animations: &mut [MaterialAnimationBinding],
+ updates: &mut Vec<AmbientEffectGeometryUpdate>,
+) -> Result<(), String> {
+ let binding = material_animations
+ .get_mut(material_animation_index)
+ .ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?;
+ binding.normalized_phase_override = Some(0.0);
+ updates.push(AmbientEffectGeometryUpdate {
+ range_index: range.range_index,
+ index_count: 0,
+ command_rgb: [0.0; 3],
+ });
+ Ok(())
+}
+
+fn write_ambient_effect_frame(
+ mesh: &mut VulkanStaticMesh,
+ range: &EnvironmentGpuRange,
+ frame: &fparkan_fx::fx_visual::EffectQuadFrame,
+) -> Result<(), String> {
+ let end = range
+ .vertex_start
+ .checked_add(range.vertex_capacity)
+ .ok_or_else(|| "ambient FX vertex range overflows".to_string())?;
+ let destination = mesh
+ .vertices
+ .get_mut(range.vertex_start..end)
+ .ok_or_else(|| "ambient FX vertex allocation is out of bounds".to_string())?;
+ if destination.len() != 4 {
+ return Err("ambient FX quad allocation changed after GPU preparation".to_string());
+ }
+ for (index, target) in destination.iter_mut().enumerate() {
+ *target = environment_vertex_with_alpha(
+ frame.vertices[index],
+ [frame.color[0], frame.color[1], frame.color[2]],
+ frame.color[3].clamp(0.0, 1.0),
+ frame.uv[index],
+ );
+ }
+ Ok(())
+}
+
+fn ambient_effect_mesh_primitive_layout(
+ frame: &fparkan_fx::fx_visual::EffectMeshFrame,
+) -> Result<Vec<AmbientEffectMeshPrimitive>, String> {
+ if frame.vertices.len() != frame.uv.len() || frame.indices.is_empty() {
+ return Err("ambient Op9 mesh has mismatched vertices/UVs or no indices".to_string());
+ }
+ let quad_count = frame
+ .indices
+ .len()
+ .checked_sub(frame.vertices.len())
+ .filter(|difference| difference.is_multiple_of(2))
+ .map(|difference| difference / 2)
+ .ok_or_else(|| "ambient Op9 mesh is not a native quad/cap layout".to_string())?;
+ let quad_vertex_count = quad_count
+ .checked_mul(4)
+ .ok_or_else(|| "ambient Op9 quad vertex count overflows".to_string())?;
+ let quad_index_count = quad_count
+ .checked_mul(6)
+ .ok_or_else(|| "ambient Op9 quad index count overflows".to_string())?;
+ let cap_vertex_count = frame
+ .vertices
+ .len()
+ .checked_sub(quad_vertex_count)
+ .filter(|count| count.is_multiple_of(3))
+ .ok_or_else(|| "ambient Op9 mesh has an invalid cap vertex count".to_string())?;
+ let cap_count = cap_vertex_count / 3;
+ let cap_index_count = cap_count
+ .checked_mul(3)
+ .ok_or_else(|| "ambient Op9 cap index count overflows".to_string())?;
+ if quad_index_count + cap_index_count != frame.indices.len() {
+ return Err("ambient Op9 quad and cap index counts do not match the frame".to_string());
+ }
+
+ // The native generator appends all quads first, then its triangle cap fan.
+ let mut primitives = Vec::with_capacity(quad_count + cap_count);
+ for quad in 0..quad_count {
+ let vertex_start = quad * 4;
+ let index_start = quad * 6;
+ let base = u16::try_from(vertex_start)
+ .map_err(|_| "ambient Op9 quad vertex exceeds u16".to_string())?;
+ let expected = [base, base + 1, base + 2, base + 2, base + 3, base];
+ if frame.indices.get(index_start..index_start + expected.len()) != Some(&expected) {
+ return Err("ambient Op9 quad topology changed from native ordering".to_string());
+ }
+ primitives.push(AmbientEffectMeshPrimitive {
+ vertex_start,
+ vertex_count: 4,
+ index_start,
+ index_count: 6,
+ });
+ }
+ for cap in 0..cap_count {
+ let vertex_start = quad_vertex_count + cap * 3;
+ let index_start = quad_index_count + cap * 3;
+ let base = u16::try_from(vertex_start)
+ .map_err(|_| "ambient Op9 cap vertex exceeds u16".to_string())?;
+ let expected = [base, base + 1, base + 2];
+ if frame.indices.get(index_start..index_start + expected.len()) != Some(&expected) {
+ return Err("ambient Op9 cap topology changed from native ordering".to_string());
+ }
+ primitives.push(AmbientEffectMeshPrimitive {
+ vertex_start,
+ vertex_count: 3,
+ index_start,
+ index_count: 3,
+ });
+ }
+ if primitives.is_empty() {
+ return Err("ambient Op9 mesh contains no native draw primitives".to_string());
+ }
+ Ok(primitives)
+}
+
+fn ambient_effect_mesh_primitive_vertices(
+ frame: &fparkan_fx::fx_visual::EffectMeshFrame,
+ primitive: AmbientEffectMeshPrimitive,
+) -> Vec<VulkanStaticVertex> {
+ let end = primitive.vertex_start + primitive.vertex_count;
+ frame.vertices[primitive.vertex_start..end]
+ .iter()
+ .zip(&frame.uv[primitive.vertex_start..end])
+ .map(|(position, uv)| {
+ environment_vertex_with_alpha(
+ *position,
+ [frame.color[0], frame.color[1], frame.color[2]],
+ frame.color[3].clamp(0.0, 1.0),
+ *uv,
+ )
+ })
+ .collect()
+}
+
+fn ambient_effect_mesh_primitive_indices(
+ frame: &fparkan_fx::fx_visual::EffectMeshFrame,
+ primitive: AmbientEffectMeshPrimitive,
+) -> Result<Vec<u32>, String> {
+ let index_end = primitive.index_start + primitive.index_count;
+ frame.indices[primitive.index_start..index_end]
+ .iter()
+ .map(|index| {
+ usize::from(*index)
+ .checked_sub(primitive.vertex_start)
+ .and_then(|local| u32::try_from(local).ok())
+ .ok_or_else(|| {
+ "ambient Op9 primitive index is outside its vertex block".to_string()
+ })
+ })
+ .collect()
+}
+
+fn write_ambient_effect_mesh_primitive(
+ mesh: &mut VulkanStaticMesh,
+ range: &EnvironmentGpuRange,
+ frame: &fparkan_fx::fx_visual::EffectMeshFrame,
+ primitive: AmbientEffectMeshPrimitive,
+) -> Result<(), String> {
+ if range.vertex_capacity != primitive.vertex_count {
+ return Err("ambient Op9 primitive vertex allocation changed".to_string());
+ }
+ let vertex_end = range
+ .vertex_start
+ .checked_add(range.vertex_capacity)
+ .ok_or_else(|| "ambient Op9 vertex range overflows".to_string())?;
+ let destination = mesh
+ .vertices
+ .get_mut(range.vertex_start..vertex_end)
+ .ok_or_else(|| "ambient Op9 vertex allocation is out of bounds".to_string())?;
+ let draw_range = mesh
+ .draw_ranges
+ .get(range.range_index)
+ .ok_or_else(|| "ambient Op9 draw range is out of bounds".to_string())?;
+ let index_start = usize::try_from(draw_range.first_index)
+ .map_err(|_| "ambient Op9 first index exceeds usize".to_string())?;
+ let index_end = index_start
+ .checked_add(primitive.index_count)
+ .ok_or_else(|| "ambient Op9 index range overflows".to_string())?;
+ let current_indices = mesh
+ .indices
+ .get(index_start..index_end)
+ .ok_or_else(|| "ambient Op9 index allocation is out of bounds".to_string())?;
+ let frame_indices = frame
+ .indices
+ .get(primitive.index_start..primitive.index_start + primitive.index_count)
+ .ok_or_else(|| "ambient Op9 source primitive index range is out of bounds".to_string())?;
+ for (current, local) in current_indices.iter().zip(frame_indices) {
+ let primitive_local = usize::from(*local)
+ .checked_sub(primitive.vertex_start)
+ .ok_or_else(|| "ambient Op9 source index leaves its primitive".to_string())?;
+ let expected = u32::try_from(range.vertex_start)
+ .ok()
+ .and_then(|base| base.checked_add(u32::try_from(primitive_local).ok()?))
+ .ok_or_else(|| "ambient Op9 primitive index exceeds u32".to_string())?;
+ if *current != expected {
+ return Err("ambient Op9 primitive topology changed after GPU preparation".to_string());
+ }
+ }
+ let frame_vertex_end = primitive
+ .vertex_start
+ .checked_add(primitive.vertex_count)
+ .ok_or_else(|| "ambient Op9 source primitive vertex range overflows".to_string())?;
+ let frame_vertices = frame
+ .vertices
+ .get(primitive.vertex_start..frame_vertex_end)
+ .ok_or_else(|| "ambient Op9 source primitive vertex range is out of bounds".to_string())?;
+ let frame_uvs = frame
+ .uv
+ .get(primitive.vertex_start..frame_vertex_end)
+ .ok_or_else(|| "ambient Op9 source primitive UV range is out of bounds".to_string())?;
+ for ((target, position), uv) in destination.iter_mut().zip(frame_vertices).zip(frame_uvs) {
+ *target = environment_vertex_with_alpha(
+ *position,
+ [frame.color[0], frame.color[1], frame.color[2]],
+ frame.color[3].clamp(0.0, 1.0),
+ *uv,
+ );
+ }
+ Ok(())
+}
+
+fn ambient_effect_vertex_rgb(command_rgb: [f32; 3], material_additive_rgb: [f32; 3]) -> [f32; 3] {
+ std::array::from_fn(|channel| command_rgb[channel] * material_additive_rgb[channel])
+}
+
+fn apply_ambient_effect_material_tints(
+ slots: &[AmbientEffectGpuSlot],
+ mesh: &mut VulkanStaticMesh,
+ updates: &[AmbientEffectGeometryUpdate],
+ active_materials: &HashMap<usize, ActiveMaterialState>,
+) -> Result<(), String> {
+ for update in updates.iter().filter(|update| update.index_count > 0) {
+ let range = slots
+ .iter()
+ .find_map(|slot| ambient_effect_range_for(slot, update.range_index))
+ .ok_or_else(|| "ambient FX draw range has no owning slot".to_string())?;
+ let material = active_materials
+ .get(&update.range_index)
+ .ok_or_else(|| "ambient FX draw range has no active MAT0 phase".to_string())?;
+ let vertex_rgb = ambient_effect_vertex_rgb(update.command_rgb, material.additive_rgb);
+ let end = range
+ .vertex_start
+ .checked_add(range.vertex_capacity)
+ .ok_or_else(|| "ambient FX vertex range overflows".to_string())?;
+ let vertices = mesh
+ .vertices
+ .get_mut(range.vertex_start..end)
+ .ok_or_else(|| "ambient FX vertex allocation is out of bounds".to_string())?;
+ for vertex in vertices {
+ vertex.color = vertex_rgb;
+ }
+ }
+ Ok(())
+}
+
+fn ambient_effect_draw_sort_keys(
+ mesh: &VulkanStaticMesh,
+ slots: &[AmbientEffectGpuSlot],
+ camera_world: [f32; 3],
+) -> Result<Vec<(usize, f32)>, String> {
+ slots.iter().try_fold(Vec::new(), |mut keys, slot| {
+ for (range, _) in ambient_effect_ranges(slot) {
+ keys.push((
+ range.range_index,
+ first_indexed_vertex_distance(mesh, range.range_index, camera_world)?,
+ ));
+ }
+ Ok(keys)
+ })
+}
+
+fn world_draw_range_indices(
+ draw_range_count: usize,
+ fixed_range_indices: &HashSet<usize>,
+) -> Vec<usize> {
+ (0..draw_range_count)
+ .filter(|range_index| !fixed_range_indices.contains(range_index))
+ .collect()
+}
+
+// TEMPORARY diagnostic for ROADMAP.md A.4 (smoke_fr_02 speed). Enabled by
+// FPARKAN_DIAG_SMOKE; identifies the emitter by its Op8 body (pool 30,
+// interval 0.08) and reports spawn rate, particle lifetime and rise speed
+// once per two seconds of effect clock. Remove after the investigation.
+#[derive(Default)]
+struct SmokeDiagnosticTrack {
+ window_start: Option<f32>,
+ last_born: f32,
+ spawns: usize,
+ calls: usize,
+ max_age: f32,
+ sum_age: f64,
+ sum_z: f64,
+ sum_age_z: f64,
+ sum_age_age: f64,
+ samples: usize,
+}
+
+fn smoke_fr_02_diagnostic(
+ slot: &AmbientEffectGpuSlot,
+ clock: f32,
+ phase: f32,
+ frames: &[fparkan_fx::fx_visual::EffectQuadFrame],
+) {
+ static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ static TRACKS: std::sync::OnceLock<
+ std::sync::Mutex<HashMap<(usize, usize, usize, usize, usize), SmokeDiagnosticTrack>>,
+ > = std::sync::OnceLock::new();
+ if !*ENABLED.get_or_init(|| std::env::var_os("FPARKAN_DIAG_SMOKE").is_some()) {
+ return;
+ }
+ let body = slot.command.raw_body.as_ref();
+ let read_u32 = |offset: usize| {
+ body.get(offset..offset + 4)
+ .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four bytes")))
+ };
+ if read_u32(0x20) != Some(30) || read_u32(0x14).map(f32::from_bits) != Some(0.08) {
+ return;
+ }
+ let key = (
+ slot.owner_object_index,
+ slot.owner_component_index,
+ slot.control_table_index,
+ slot.control_row_index,
+ slot.command_index,
+ );
+ let mut tracks = TRACKS
+ .get_or_init(|| std::sync::Mutex::new(HashMap::new()))
+ .lock()
+ .expect("smoke diagnostic lock");
+ let slot_count = tracks.len() + usize::from(!tracks.contains_key(&key));
+ let track = tracks.entry(key).or_default();
+ let window_start = *track.window_start.get_or_insert(clock);
+ track.calls += 1;
+ for frame in frames.iter().filter(|frame| frame.active) {
+ let born = clock - frame.elapsed_seconds;
+ if born > track.last_born + 1e-4 {
+ track.spawns += 1;
+ track.last_born = track.last_born.max(born);
+ }
+ let z = frame.vertices.iter().map(|vertex| vertex[2]).sum::<f32>() * 0.25;
+ let age = f64::from(frame.elapsed_seconds);
+ track.max_age = track.max_age.max(frame.elapsed_seconds);
+ track.sum_age += age;
+ track.sum_z += f64::from(z);
+ track.sum_age_z += age * f64::from(z);
+ track.sum_age_age += age * age;
+ track.samples += 1;
+ }
+ let window = clock - window_start;
+ if window < 2.0 {
+ return;
+ }
+ let n = track.samples as f64;
+ let rise_per_second = if n > 1.0 {
+ (n * track.sum_age_z - track.sum_age * track.sum_z)
+ / (n * track.sum_age_age - track.sum_age * track.sum_age)
+ } else {
+ 0.0
+ };
+ let axis_lengths: [f32; 3] = std::array::from_fn(|column| {
+ (0..3)
+ .map(|row| slot.world_from_effect[row][column].powi(2))
+ .sum::<f32>()
+ .sqrt()
+ });
+ eprintln!(
+ "smoke_diag slots={slot_count} key={key:?} clock={clock:.3} window={window:.3}s calls={} calls_per_s={:.1} spawns={} spawns_per_s={:.2} active_now={} max_age={:.3}s rise_per_s={rise_per_second:.2} rise_per_life_2_4s={:.1} phase={phase:.3} emitter=[{:.2},{:.2},{:.2}] effect_axes={axis_lengths:.3?}",
+ track.calls,
+ track.calls as f32 / window,
+ track.spawns,
+ track.spawns as f32 / window,
+ frames.iter().filter(|frame| frame.active).count(),
+ track.max_age,
+ rise_per_second * 2.4,
+ slot.world_from_effect[0][3],
+ slot.world_from_effect[1][3],
+ slot.world_from_effect[2][3],
+ );
+ *track = SmokeDiagnosticTrack {
+ window_start: Some(clock),
+ last_born: track.last_born,
+ ..SmokeDiagnosticTrack::default()
+ };
+}
+
+// TEMPORARY diagnostic for ROADMAP.md A.4 (animal "aura" speed). Enabled by
+// FPARKAN_DIAG_FX_MATERIAL=<material name>, for example smoke_yg. Reports the
+// effect-phase rate, call rate, particle lifetime and rise per particle life
+// for every Op7/Op8 slot whose command uses that MAT0. Remove after the
+// investigation.
+#[derive(Default)]
+struct FxSpeedDiagnosticTrack {
+ window_start: Option<f32>,
+ calls: usize,
+ phase_travel: f32,
+ last_phase: Option<f32>,
+ max_age: f32,
+ sum_t: f64,
+ sum_z: f64,
+ sum_tz: f64,
+ sum_tt: f64,
+ samples: usize,
+}
+
+fn fx_material_speed_diagnostic(
+ slot: &AmbientEffectGpuSlot,
+ material_name_offset: usize,
+ clock: f32,
+ phase: f32,
+ frames: &[fparkan_fx::fx_visual::EffectQuadFrame],
+) {
+ static WANTED: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
+ static TRACKS: std::sync::OnceLock<
+ std::sync::Mutex<HashMap<(usize, usize, usize, usize, usize), FxSpeedDiagnosticTrack>>,
+ > = std::sync::OnceLock::new();
+ let Some(wanted) = WANTED
+ .get_or_init(|| {
+ std::env::var("FPARKAN_DIAG_FX_MATERIAL")
+ .ok()
+ .map(|name| name.to_ascii_lowercase())
+ })
+ .as_deref()
+ else {
+ return;
+ };
+ let body = slot.command.raw_body.as_ref();
+ let Some(name) = body.get(material_name_offset..material_name_offset + 32) else {
+ return;
+ };
+ let name = name.split(|byte| *byte == 0).next().unwrap_or_default();
+ if !name.eq_ignore_ascii_case(wanted.as_bytes()) {
+ return;
+ }
+ let key = (
+ slot.owner_object_index,
+ slot.owner_component_index,
+ slot.control_table_index,
+ slot.control_row_index,
+ slot.command_index,
+ );
+ let mut tracks = TRACKS
+ .get_or_init(|| std::sync::Mutex::new(HashMap::new()))
+ .lock()
+ .expect("fx diagnostic lock");
+ let slot_count = tracks.len() + usize::from(!tracks.contains_key(&key));
+ let track = tracks.entry(key).or_default();
+ let window_start = *track.window_start.get_or_insert(clock);
+ track.calls += 1;
+ if let Some(last_phase) = track.last_phase {
+ let delta = phase - last_phase;
+ track.phase_travel += if delta < -0.5 { delta + 1.0 } else { delta };
+ }
+ track.last_phase = Some(phase);
+ for frame in frames.iter().filter(|frame| frame.active) {
+ // Op8 reports the particle age in seconds; Op7 reports its normalized
+ // particle age, so the regression is per second or per life.
+ let t = if slot.command.opcode == FxOpcode::Op8 {
+ frame.elapsed_seconds
+ } else {
+ frame.normalized_time
+ };
+ let z = frame.vertices.iter().map(|vertex| vertex[2]).sum::<f32>() * 0.25;
+ track.max_age = track.max_age.max(t);
+ track.sum_t += f64::from(t);
+ track.sum_z += f64::from(z);
+ track.sum_tz += f64::from(t) * f64::from(z);
+ track.sum_tt += f64::from(t) * f64::from(t);
+ track.samples += 1;
+ }
+ let window = clock - window_start;
+ if window < 3.0 {
+ return;
+ }
+ let n = track.samples as f64;
+ let slope = if n > 1.0 {
+ (n * track.sum_tz - track.sum_t * track.sum_z)
+ / (n * track.sum_tt - track.sum_t * track.sum_t)
+ } else {
+ 0.0
+ };
+ eprintln!(
+ "fx_diag material={wanted} op={:?} slots={slot_count} key={key:?} clock={clock:.3} window={window:.3}s calls_per_s={:.1} phase_rate_per_s={:.4} active_now={} max_age={:.3} rise_slope={slope:.2} ({}) duration={:.2} mode={} owner=[{:.1},{:.1},{:.1}]",
+ slot.command.opcode,
+ track.calls as f32 / window,
+ track.phase_travel / window,
+ frames.iter().filter(|frame| frame.active).count(),
+ track.max_age,
+ if slot.command.opcode == FxOpcode::Op8 { "units/s, age in s" } else { "units/life, age normalized" },
+ slot.duration_seconds,
+ slot.time_mode,
+ slot.world_from_effect[0][3],
+ slot.world_from_effect[1][3],
+ slot.world_from_effect[2][3],
+ );
+ *track = FxSpeedDiagnosticTrack {
+ window_start: Some(clock),
+ last_phase: track.last_phase,
+ ..FxSpeedDiagnosticTrack::default()
+ };
+}
+
+fn ambient_effect_phase(
+ time_mode: u32,
+ header_flags: u32,
+ elapsed_seconds: f32,
+ duration_seconds: f32,
+) -> Option<f32> {
+ if !elapsed_seconds.is_finite()
+ || elapsed_seconds < 0.0
+ || !duration_seconds.is_finite()
+ || duration_seconds < 0.0
+ {
+ return None;
+ }
+ let mut phase = match time_mode {
+ 0 => 0.0,
+ // The native phase stays raw through the optional 0x200/0x20
+ // transforms; the command-level 0x8120 path clamps only afterward.
+ 1 if duration_seconds > 0.0 => elapsed_seconds / duration_seconds,
+ 2 if duration_seconds > 0.0 => {
+ elapsed_seconds.rem_euclid(duration_seconds) / duration_seconds
+ }
+ _ => return None,
+ };
+ if header_flags & 0x200 != 0 && duration_seconds > 0.0 {
+ phase *= elapsed_seconds / duration_seconds;
+ }
+ if header_flags & 0x20 != 0 {
+ phase = if phase >= 0.5 {
+ (1.0 - phase) * 2.0
+ } else {
+ phase * 2.0
+ };
+ }
+ Some(phase)
+}
+
fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
let [x, y, z, w] = rotation;
let tx = 2.0 * (y * value[2] - z * value[1]);
@@ -4443,163 +7073,890 @@ fn initial_control_gate_values(control: &PreparedComponentControl) -> Vec<Option
.collect()
}
-fn control_has_tag21(control: &PreparedComponentControl) -> bool {
- control.typed_records.iter().any(|record| record.tag == 21)
+fn control_clock_ms_for_frame(target_ms: u32, frame_index: u64, target_frames: u64) -> u32 {
+ if target_frames <= 1 {
+ return target_ms;
+ }
+ let last_frame = u128::from(target_frames - 1);
+ let frame = u128::from(frame_index).min(last_frame);
+ ((u128::from(target_ms) * frame) / last_frame) as u32
+}
+
+fn replay_environment_delta_seconds(last_applied_ms: u32, current_ms: u32) -> f32 {
+ current_ms.saturating_sub(last_applied_ms) as f32 / 1_000.0
}
-fn component_chain_has_tag21(
- tree: Option<&[UnitComponentTreeNode]>,
- controls: &[Option<PreparedComponentControl>],
+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,
})
}
@@ -4625,22 +7982,62 @@ fn static_preview_mesh_and_materials(
let mut type16_present_ranges = HashSet::new();
let mut materials = Vec::new();
let mut material_animations = Vec::new();
- let mut sun_occlusion_range_indices = Vec::new();
- let mut shadow_casters = Vec::new();
- let mut shadow_sphere_baselines = Vec::new();
- let mut shadow_caster_indices_by_object = Vec::with_capacity(roots.len());
+ let mut ambient_effect_slots = Vec::new();
+ let mut control_point_light_slots = Vec::new();
+ let mut control_sound_slots = Vec::new();
+ let mut unsupported_control_sounds = 0;
+ let mut effect_documents = HashMap::new();
+ let mut effect_materials = HashMap::new();
let mut component_pose_buffers_by_object = Vec::with_capacity(roots.len());
let mut component_mount_poses_by_object = Vec::with_capacity(roots.len());
- let mut control_animation_instances = Vec::new();
- let mut dynamic_component_geometry = Vec::new();
- let mut dynamic_component_mounts = Vec::new();
+ let mut dynamic_component_geometry_by_object = Vec::with_capacity(roots.len());
let mut building_collision_bindings = Vec::new();
let mut collision_models =
HashMap::<fparkan_assets::AssetId<PreparedModel>, Arc<ModelAsset>>::new();
- let shadow_receivers = terrain_shadow_receivers(terrain_mesh)?;
+ let mut control_animation_instances_by_object = Vec::with_capacity(roots.len());
+ let mut dynamic_shadow_casters = Vec::new();
+ let mut sun_occlusion_range_indices = Vec::new();
+ let mut shadow_casters = Vec::new();
let mut terrain_material_loader = StandaloneWearMaterialLoader::new(root);
- let terrain_component = project_land_msh_to_static_mesh_in_world_space(terrain_mesh)
+ let source_terrain_component = project_land_msh_to_static_mesh_in_world_space(terrain_mesh)
.map_err(|err| format!("project mission terrain for Vulkan: {err}"))?;
+ let footprint_sources = mission_basement_footprints(assets, roots)?;
+ let foundation = if footprint_sources.is_empty() {
+ None
+ } else {
+ let footprints = footprint_sources
+ .iter()
+ .map(|footprint| BasementFootprint {
+ outer: &footprint.outer,
+ inner: &footprint.inner,
+ flatten_inner_to_mean_outer_z: footprint.flatten_inner_to_mean_outer_z,
+ })
+ .collect::<Vec<_>>();
+ Some(
+ build_basement_foundations(terrain_mesh, &footprints)
+ .map_err(|error| format!("prepare mission basement foundations: {error}"))?,
+ )
+ };
+ let effective_roots = foundation
+ .as_ref()
+ .map(|foundation| {
+ apply_basement_placement_deltas(roots, &footprint_sources, &foundation.placements)
+ })
+ .transpose()?;
+ // Every visual, mounted component, effect, and shadow caster below uses this
+ // same root transform, so the native delta is applied once to the hierarchy.
+ let roots = effective_roots.as_deref().unwrap_or(roots);
+ let (terrain_component, shadow_receivers, terrain_query) = prepare_static_terrain_geometry(
+ terrain_mesh,
+ source_terrain_component,
+ terrain,
+ foundation,
+ )?;
+ let (terrain_component, terrain_lod) = terrain_lod::prepare(
+ terrain_mesh,
+ terrain_component,
+ terrain_query.basement_foundation(),
+ )?;
let terrain_materials = static_preview_terrain_base_materials(
&mut terrain_material_loader,
root,
@@ -4656,6 +8053,7 @@ fn static_preview_mesh_and_materials(
&terrain_materials,
Some(&mut material_animations),
)?;
+ let dynamic_vertex_start = mesh.vertices.len();
let mut mesh_components = 0;
for (object_index, mission_root) in roots.iter().enumerate() {
let visual_ids = assets.visuals_for_object(object_index);
@@ -4705,7 +8103,6 @@ fn static_preview_mesh_and_materials(
component_controls.map_or(0, Vec::len)
));
}
- let component_controls = component_controls.map_or(&[][..], Vec::as_slice);
let root_native_kind = component_refs
.first()
.and_then(|(visual, _)| native_shadow_kind(visual.source_type));
@@ -4716,61 +8113,76 @@ 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,
+ &mut control_sound_slots,
+ &mut unsupported_control_sounds,
+ )?;
+ }
+ }
continue;
};
- let component_control = component_controls
- .get(component_index)
- .and_then(Option::as_ref);
- let control_animates_component = component_control.is_some_and(control_has_tag21);
+ let component_bindings = component_controls
+ .and_then(|controls| controls.get(component_index))
+ .and_then(Option::as_ref)
+ .map_or(&[][..], |control| control.bindings.as_slice());
+ if static_animation_frame.is_none() {
+ if let Some(control) = control {
+ control_animation_instances[component_index] = Some(ControlAnimationInstance {
+ control: control.clone(),
+ model: model.validated.clone(),
+ gate_values: initial_control_gate_values(control),
+ state: ControlInstanceState::default(),
+ last_sampled_node_times: Vec::new(),
+ reported_unsupported_fallback: false,
+ });
+ }
+ }
+ component_pose_buffers[component_index] = static_preview_component_pose_buffer(
+ &model.validated,
+ component_bindings,
+ static_animation_frame,
+ )?;
let timeline_moves_component = static_animation_frame.is_none()
- && (control_animates_component
- || component_chain_has_tag21(
- tree.as_deref(),
- component_controls,
- component_index,
- ));
- let runtime_model = timeline_moves_component.then(|| Arc::new(model.validated.clone()));
- let mut control_state = ControlInstanceState::default();
- component_pose_buffers[component_index] =
- match (static_animation_frame, component_control) {
- (None, Some(control)) => static_preview_control_pose_buffer(
- &model.validated,
- control,
- 0,
- &mut control_state,
- )?,
- (animation_frame, control) => static_preview_component_pose_buffer(
- &model.validated,
- control.map_or(&[][..], |control| control.bindings.as_slice()),
- animation_frame,
- )?,
- };
- if control_animates_component && static_animation_frame.is_none() {
- control_animation_instances.push(ControlAnimationInstance {
- object_index,
+ && control_tree_chain_has_tag21(
component_index,
- model: runtime_model
- .as_ref()
- .map(Arc::clone)
- .ok_or_else(|| "animated component model was not retained".to_string())?,
- control: component_control
- .ok_or_else(|| "tag-21 component control disappeared".to_string())?
- .clone(),
- state: control_state,
- });
- }
+ tree.as_deref(),
+ component_controls.map_or(&[][..], |controls| controls.as_slice()),
+ );
// Native AniMesh only resolves global MSH node sockets for EXTO
// geometry. Internal INTO components use controller/equipment
// slots, whose `parent_or_link` values are not MSH node indices.
@@ -4862,30 +8274,33 @@ 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,
+ &mut control_sound_slots,
+ &mut unsupported_control_sounds,
+ )?;
+ }
}
let transform = LegacyIron3dEulerTransform {
@@ -4967,11 +8382,6 @@ fn static_preview_mesh_and_materials(
native_msh_cull_mode(range.batch_flags, component_handedness);
}
let component_shadow_triangles = static_mesh_shadow_triangles(&component);
- let shadow_triangle_range = root_native_kind.map(|_| {
- let start = root_shadow_triangles.len();
- let end = start.saturating_add(component_shadow_triangles.len());
- start..end
- });
if root_native_kind.is_some() {
root_shadow_triangles.extend(component_shadow_triangles);
let node1_pose = mount_pose.and_then(|_| match pose_buffer {
@@ -4989,6 +8399,12 @@ fn static_preview_mesh_and_materials(
node1_pose,
) {
root_shadow_spheres.push(sphere);
+ root_shadow_sphere_sources.push(DynamicShadowSphereSource {
+ component_index,
+ center: model.validated.bounding_sphere_center,
+ radius: model.validated.bounding_sphere_radius,
+ mounted: mount_pose.is_some(),
+ });
}
}
let selector_remap = static_preview_component_materials(
@@ -5000,9 +8416,9 @@ fn static_preview_mesh_and_materials(
&mut material_animations,
)?;
let lightmap_textures = static_preview_lightmap_textures(assets, visual)?;
+ let range_start = mesh.draw_ranges.len();
let vertex_start = mesh.vertices.len();
let vertex_count = component.vertices.len();
- let range_start = mesh.draw_ranges.len();
append_static_preview_component_with_lightmaps(
&mut mesh,
component,
@@ -5017,54 +8433,90 @@ fn static_preview_mesh_and_materials(
type16_present_ranges.insert(range_index);
}
}
- if root_sun_eligible {
- sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len());
+ if root_native_kind.is_some() {
+ root_shadow_draw_range_indices.extend(range_start..mesh.draw_ranges.len());
}
- if timeline_moves_component && vertex_count != 0 {
- dynamic_component_geometry.push(DynamicComponentGeometry {
- object_index,
- component_index,
- model: runtime_model.as_ref().map(Arc::clone).ok_or_else(|| {
- "animated component model was not retained".to_string()
- })?,
- transform,
- scale: mission_root.scale,
+ if timeline_moves_component {
+ let parent_mount = tree
+ .as_ref()
+ .and_then(|nodes| nodes.get(component_index))
+ .and_then(|node| node.parent_index)
+ .filter(|parent_index| {
+ component_mount_poses[*parent_index].is_some()
+ || component_refs.get(*parent_index).is_some_and(
+ |(parent_visual, _)| {
+ unit_component_is_visible(
+ *parent_index,
+ parent_visual.source_type,
+ )
+ },
+ )
+ })
+ .map(|parent_index| {
+ let socket = usize::try_from(records[component_index].parent_or_link)
+ .expect("mount socket was validated above");
+ (parent_index, socket)
+ });
+ dynamic_component_geometry[component_index] = Some(DynamicComponentGeometry {
+ model: model.validated.clone(),
+ root_transform: transform,
+ owner_scale: mission_root.scale,
+ parent_mount,
vertex_start,
vertex_count,
- shadow_triangle_range,
});
}
+ if root_sun_eligible {
+ sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len());
+ }
mesh_components += 1;
}
}
- component_pose_buffers_by_object.push(component_pose_buffers);
- component_mount_poses_by_object.push(component_mount_poses);
- let shadow_caster_index = if let Some(native_kind) =
+ if let Some(native_kind) =
root_native_kind.filter(|kind| native_shadow_caster_enabled(*kind))
{
let caster_id = u32::try_from(shadow_casters.len())
.map_err(|_| "shadow caster id exceeds u32".to_string())?;
- aggregate_shadow_caster(
+ let root_transform = LegacyIron3dEulerTransform {
+ translation: mission_root.position,
+ orientation_radians: mission_root.orientation_raw,
+ };
+ if let Some(caster) = aggregate_shadow_caster(
caster_id,
native_kind,
- LegacyIron3dEulerTransform {
- translation: mission_root.position,
- orientation_radians: mission_root.orientation_raw,
- },
+ root_transform,
mission_root.scale,
root_shadow_triangles,
&root_shadow_spheres,
- )
- .map(|caster| {
- let index = shadow_casters.len();
- shadow_sphere_baselines.push(caster.sphere);
+ ) {
+ if dynamic_component_geometry.iter().any(Option::is_some) {
+ let component_draw_range_indices = root_shadow_draw_range_indices
+ .into_iter()
+ .filter(|range_index| {
+ mesh.draw_ranges
+ .get(*range_index)
+ .is_some_and(|range| range.index_count != 0)
+ })
+ .collect::<Vec<_>>();
+ if !component_draw_range_indices.is_empty() {
+ dynamic_shadow_casters.push(DynamicShadowCaster {
+ caster_index: shadow_casters.len(),
+ object_index,
+ root_transform,
+ owner_scale: mission_root.scale,
+ initial_sphere: caster.sphere,
+ component_draw_range_indices,
+ component_spheres: root_shadow_sphere_sources.clone(),
+ });
+ }
+ }
shadow_casters.push(caster);
- index
- })
- } else {
- None
- };
- shadow_caster_indices_by_object.push(shadow_caster_index);
+ }
+ }
+ component_pose_buffers_by_object.push(component_pose_buffers);
+ component_mount_poses_by_object.push(component_mount_poses);
+ dynamic_component_geometry_by_object.push(dynamic_component_geometry);
+ control_animation_instances_by_object.push(control_animation_instances);
}
if mesh_components == 0 {
return Err("selected static preview roots have no mesh-backed visual".to_string());
@@ -5089,23 +8541,27 @@ fn static_preview_mesh_and_materials(
let shadow_material_index = append_shadow_material(&mut materials)?;
Ok(StaticPreviewScene {
mesh,
+ dynamic_vertex_start,
+ terrain_lod,
native_handedness_by_range,
type16_present_ranges,
materials,
material_animations,
+ ambient_effect_slots,
+ control_point_light_slots,
+ control_sound_slots,
+ unsupported_control_sounds,
+ component_pose_buffers: component_pose_buffers_by_object,
+ component_mount_poses: component_mount_poses_by_object,
+ dynamic_component_geometry: dynamic_component_geometry_by_object,
+ building_collision_bindings,
+ control_animation_instances: control_animation_instances_by_object,
+ dynamic_shadow_casters,
sun_occlusion_range_indices,
shadow_casters,
- shadow_sphere_baselines,
shadow_receivers,
shadow_material_index,
- terrain: Arc::new(terrain.clone()),
- component_pose_buffers: component_pose_buffers_by_object,
- component_mount_poses: component_mount_poses_by_object,
- control_animation_instances,
- dynamic_component_geometry,
- building_collision_bindings,
- dynamic_component_mounts,
- shadow_caster_indices_by_object,
+ terrain: Arc::new(terrain_query),
camera,
view_camera,
captured_viewport,
@@ -5211,14 +8667,38 @@ fn static_preview_terrain_base_materials(
}
let initial_phase_index =
usize::from(static_material_phase.unwrap_or(0)).min(phase_count.saturating_sub(1));
- let initial_detail_phase_index = usize::from(static_material_phase.unwrap_or(0))
- .min(detail_phases.len().saturating_sub(1));
+ let initial_detail_phase_index = if let Some(phase) = static_material_phase {
+ usize::from(phase).min(detail_phases.len().saturating_sub(1))
+ } else {
+ sample_material_phase(
+ &detail_document,
+ layers.detail_base_selection().animation_block_index,
+ 0,
+ 0,
+ 0,
+ )
+ .map_err(|error| format!("sample terrain detail material: {error}"))?
+ .phase_index
+ };
let initial_overlay_phase_index = overlay_phases.as_ref().map(|phases| {
usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1))
});
- let initial_overlay_detail_phase_index = overlay_detail_phases.as_ref().map(|phases| {
- usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1))
- });
+ let initial_overlay_detail_phase_index = overlay_detail_phases
+ .as_ref()
+ .zip(overlay_detail_document.as_ref())
+ .zip(overlay_detail_selection)
+ .map(|((phases, document), selection)| {
+ if let Some(phase) = static_material_phase {
+ Ok(usize::from(phase).min(phases.len().saturating_sub(1)))
+ } else {
+ sample_material_phase(document, selection.animation_block_index, 0, 0, 0)
+ .map(|sample| sample.phase_index)
+ .map_err(|error| {
+ format!("sample terrain overlay detail material: {error}")
+ })
+ }
+ })
+ .transpose()?;
let mut phase_material_indices = Vec::with_capacity(phase_count);
for phase_index in 0..phase_count {
let (base, base_phase) = &base_phases[phase_index];
@@ -5331,9 +8811,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,
@@ -5345,7 +8828,7 @@ fn static_preview_terrain_base_materials(
},
detail: Some(MaterialPhaseBinding {
document: detail_document,
- animation_block_index: 0,
+ animation_block_index: layers.detail_base_selection().animation_block_index,
wear_row_start_ms: 0,
random_state: packed_tag as u32 ^ 0x5A5A_A5A5,
frozen_phase_index: static_material_phase.map(usize::from),
@@ -5361,7 +8844,9 @@ fn static_preview_terrain_base_materials(
}),
overlay_detail: overlay_detail_document.map(|document| MaterialPhaseBinding {
document,
- animation_block_index: 0,
+ animation_block_index: overlay_detail_selection
+ .expect("overlay detail selection")
+ .animation_block_index,
wear_row_start_ms: 0,
random_state: packed_tag as u32 ^ 0xC3C3_3C3C,
frozen_phase_index: static_material_phase.map(usize::from),
@@ -5481,9 +8966,12 @@ fn static_preview_component_materials(
.map_err(|_| "MAT0 phase variant selector exceeds u16".to_string())?;
material_animations.push(MaterialAnimationBinding {
initial_phase_index,
+ normalized_phase_override: None,
+ ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices,
range_indices: Vec::new(),
+ pipeline_category: material.pipeline_category,
}],
base: MaterialPhaseBinding {
document: material.mat0.clone(),
@@ -5598,7 +9086,7 @@ fn append_static_preview_component_with_lightmaps(
// advances to another phase. The range's original selector identifies
// the base binding, while the lightmap selector identifies the secondary
// texture. Keep the generated phase family and the binding/variant it
- // belongs to so the pending MAT0 packet can rebind every frame.
+ // belongs to so update_material_animations can rebind every frame.
let mut lightmap_materials: HashMap<(usize, u8), (Vec<usize>, usize, usize)> = HashMap::new();
for range in component.draw_ranges {
let source_material_index = selector_remap
@@ -5630,6 +9118,7 @@ fn append_static_preview_component_with_lightmaps(
"static preview material selector {source_material_index} has no MAT0 animation binding"
)
})?;
+ let initial_phase_index = bindings[binding_index].initial_phase_index;
let lightmap = lightmap_textures
.get(usize::from(range.lightmap_index))
.expect("lightmap presence checked above");
@@ -5669,6 +9158,16 @@ fn append_static_preview_component_with_lightmaps(
materials.push(lightmap_material);
phase_material_indices.push(usize::from(index));
}
+ let initial_selector = phase_material_indices
+ .get(initial_phase_index)
+ .copied()
+ .ok_or_else(|| "static preview lightmap phase table is empty".to_string())?;
+ let pipeline_category = materials
+ .get(initial_selector)
+ .map(|material| material.pipeline_category)
+ .ok_or_else(|| {
+ "static preview lightmap material selector is unavailable".to_string()
+ })?;
let bindings = material_animations.as_deref_mut().ok_or_else(|| {
"static preview lightmap animation storage is unavailable".to_string()
})?;
@@ -5676,6 +9175,7 @@ fn append_static_preview_component_with_lightmaps(
bindings[binding_index].variants.push(MaterialVariantSet {
phase_material_indices: phase_material_indices.clone(),
range_indices: Vec::new(),
+ pipeline_category,
});
let value = (phase_material_indices, binding_index, variant_index);
lightmap_materials.insert(key, value.clone());
@@ -5782,11 +9282,12 @@ fn run_static_vulkan_mode(
environment: Option<DynamicEnvironment>,
audio: Option<audio::GameAudio>,
target_frames: u64,
- control_clock_target_ms: Option<u32>,
mission: &str,
object_count: usize,
readback_out: Option<&std::path::Path>,
+ readback_sequence_out: Option<&std::path::Path>,
enable_validation: bool,
+ control_clock_target_ms: Option<u32>,
startup_timings: StartupTimings,
) -> Result<String, String> {
let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?;
@@ -5801,7 +9302,7 @@ fn run_static_vulkan_mode(
preview.shadow_material_index,
shadow_placeholder_vertices(),
quad_indices(1)?,
- world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
+ projected_shadow_pipeline_state(),
)?;
let environment_gpu = match environment.as_mut() {
Some(environment) => {
@@ -5815,6 +9316,8 @@ fn run_static_vulkan_mode(
&mut preview.type16_present_ranges,
&mut preview.materials,
&mut preview.material_animations,
+ &mut preview.ambient_effect_slots,
+ &mut preview.dynamic_shadow_casters,
&mut preview.sun_occlusion_range_indices,
&environment_materials,
&material_assets,
@@ -5836,11 +9339,12 @@ fn run_static_vulkan_mode(
shadow_base_index,
audio,
target_frames,
- control_clock_target_ms,
mission,
object_count,
readback_out.map(std::path::Path::to_path_buf),
+ readback_sequence_out.map(std::path::Path::to_path_buf),
enable_validation,
+ control_clock_target_ms,
startup_timings,
);
if let Err(err) = event_loop.run_app(&mut app) {
@@ -5849,98 +9353,27 @@ 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,
+ dynamic_vertex_start: usize,
+ terrain_lod: Option<terrain_lod::TerrainLodDraws>,
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<ControlEffectSlot>,
+ control_sound_slots: Vec<ControlSoundSlot>,
+ unsupported_control_sounds: usize,
+ control_effect_random_states: HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
- control_animation_instances: Vec<ControlAnimationInstance>,
- dynamic_component_geometry: Vec<DynamicComponentGeometry>,
+ dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>,
building_collision_bindings: Vec<BuildingCollisionBinding>,
- dynamic_component_mounts: Vec<DynamicComponentMount>,
- shadow_caster_indices_by_object: Vec<Option<usize>>,
- shadow_sphere_baselines: Vec<ShadowSphere>,
+ control_animation_instances: Vec<Vec<Option<ControlAnimationInstance>>>,
+ dynamic_shadow_casters: Vec<DynamicShadowCaster>,
+ sun_occlusion_range_indices: Vec<usize>,
+ terrain: Arc<TerrainWorld>,
world_range_indices: Vec<usize>,
world_range_index_counts: HashMap<usize, u32>,
camera: VulkanStaticCamera,
@@ -5957,11 +9390,16 @@ struct StaticVulkanApp {
shadow_receivers: Vec<ShadowTriangle>,
shadow_cache: ShadowPageCache,
shadow_last_evidence: ShadowFrameEvidence,
- material_time_ms: u32,
+ last_environment_control_clock_ms: u32,
+ control_point_lights_diagnostic_reported: bool,
+ simulation_last_tick: Instant,
+ live_clock_paused_for_zero_extent: bool,
+ simulation_elapsed: Duration,
+ simulation_time_ms: u32,
+ simulation_update_generation: u64,
+ pending_scene_update: Option<SceneFrameUpdate>,
environment_primitive_count: usize,
pressed_keys: HashSet<KeyCode>,
- last_tick: Instant,
- simulation_elapsed: Duration,
mouse_look: bool,
audio: Option<audio::GameAudio>,
camera_mode: &'static str,
@@ -5972,12 +9410,13 @@ struct StaticVulkanApp {
mission: String,
object_count: usize,
readback_out: Option<PathBuf>,
+ readback_sequence_out: Option<PathBuf>,
enable_validation: bool,
startup_timings: StartupTimings,
window_id: Option<WindowId>,
window: Option<Window>,
renderer: Option<VulkanSmokeRenderer>,
- lifecycle: SceneFrameLifecycle,
+ frames_presented: u64,
output: Option<String>,
error: Option<String>,
}
@@ -5992,21 +9431,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
@@ -6017,20 +9463,25 @@ impl StaticVulkanApp {
.collect();
Self {
mesh: preview.mesh,
+ dynamic_vertex_start: preview.dynamic_vertex_start,
+ terrain_lod: preview.terrain_lod,
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_sound_slots: preview.control_sound_slots,
+ unsupported_control_sounds: preview.unsupported_control_sounds,
+ control_effect_random_states: HashMap::new(),
component_pose_buffers: preview.component_pose_buffers,
component_mount_poses: preview.component_mount_poses,
- control_animation_instances: preview.control_animation_instances,
dynamic_component_geometry: preview.dynamic_component_geometry,
building_collision_bindings: preview.building_collision_bindings,
- dynamic_component_mounts: preview.dynamic_component_mounts,
- shadow_caster_indices_by_object: preview.shadow_caster_indices_by_object,
- shadow_sphere_baselines: preview.shadow_sphere_baselines,
+ control_animation_instances: preview.control_animation_instances,
+ dynamic_shadow_casters: preview.dynamic_shadow_casters,
+ sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
+ terrain: preview.terrain,
world_range_indices,
world_range_index_counts,
camera: preview.camera,
@@ -6047,11 +9498,16 @@ impl StaticVulkanApp {
shadow_receivers: preview.shadow_receivers,
shadow_cache: ShadowPageCache::default(),
shadow_last_evidence: ShadowFrameEvidence::default(),
- material_time_ms: 0,
+ last_environment_control_clock_ms: 0,
+ control_point_lights_diagnostic_reported: false,
+ simulation_last_tick: Instant::now(),
+ live_clock_paused_for_zero_extent: false,
+ simulation_elapsed: Duration::ZERO,
+ simulation_time_ms: 0,
+ simulation_update_generation: 0,
+ pending_scene_update: None,
environment_primitive_count: 0,
pressed_keys: HashSet::new(),
- last_tick: Instant::now(),
- simulation_elapsed: Duration::ZERO,
mouse_look: false,
audio,
camera_mode: preview.camera_mode,
@@ -6062,12 +9518,13 @@ impl StaticVulkanApp {
mission: mission.to_string(),
object_count,
readback_out,
+ readback_sequence_out,
enable_validation,
startup_timings,
window_id: None,
window: None,
renderer: None,
- lifecycle: SceneFrameLifecycle::default(),
+ frames_presented: 0,
output: None,
error: None,
}
@@ -6076,7 +9533,41 @@ impl StaticVulkanApp {
fn configure_world_draw_ranges(
&self,
renderer: &mut VulkanSmokeRenderer,
+ aspect: f32,
) -> Result<(), String> {
+ if let Some(lod) = &self.terrain_lod {
+ lod.report(
+ self.view_camera.position,
+ horizontal_fov(&self.view_camera, aspect),
+ )?;
+ }
+ let approximate_portals = self
+ .world_range_indices
+ .iter()
+ .filter(|&&index| {
+ let flags = self.mesh.draw_ranges[index].batch_flags;
+ flags & 8 != 0
+ && flags & 0x50 != 0x40
+ && !self.type16_present_ranges.contains(&index)
+ })
+ .count();
+ eprintln!("render_coverage portal_fade_packet_selection_approx={approximate_portals} (first indexed vertex; native CPU clipping selection pending)");
+ let randomized_lights = self
+ .control_point_light_slots
+ .iter()
+ .filter(|slot| {
+ [92..108, 116..120].into_iter().any(|range| {
+ slot.command.raw_body.get(range).is_some_and(|bytes| {
+ bytes
+ .chunks_exact(4)
+ .any(|word| f32::from_le_bytes(word.try_into().unwrap()) != 0.0)
+ })
+ })
+ })
+ .count();
+ eprintln!("render_coverage control_op1_jitter_unsupported={randomized_lights} (using authored color and radius; native RNG sampling pending)");
+ eprintln!("render_coverage control_op2_supported={} control_op2_unsupported={} (unsupported timing, backend mode or parameters are skipped)", self.control_sound_slots.len(), self.unsupported_control_sounds);
+ eprintln!("render_coverage fog_pass_eligibility_approx={} (global world fog and horizon RGB; native shade-state/color verification pending)", self.world_range_indices.len());
for &range_index in &self.world_range_indices {
let range = self
.mesh
@@ -6088,9 +9579,12 @@ impl StaticVulkanApp {
.get(usize::from(range.material_index))
.map_or(1.0, |material| material.diffuse_alpha);
let alpha_override = native_msh_diffuse_alpha_override(
- range.batch_flags,
+ &self.mesh,
+ range_index,
self.type16_present_ranges.contains(&range_index),
- );
+ self.view_camera.position,
+ horizontal_fov(&self.view_camera, aspect),
+ )?;
let effective_alpha = alpha_override.unwrap_or(alpha);
renderer
.set_draw_range_diffuse_alpha_override(range_index, alpha_override)
@@ -6111,18 +9605,26 @@ impl StaticVulkanApp {
.map_err(|error| {
format!("configure world material category {range_index}: {error}")
})?;
- renderer
- .set_draw_range_index_count(
+ let count = world_draw_range_index_count(
+ &self.mesh,
+ range,
+ range.index_count,
+ effective_alpha,
+ self.view_camera.position,
+ self.native_handedness_by_range.get(&range_index).copied(),
+ );
+ let count = if let Some(lod) = &self.terrain_lod {
+ lod.index_count(
range_index,
- world_draw_range_index_count(
- &self.mesh,
- range,
- range.index_count,
- effective_alpha,
- self.view_camera.position,
- self.native_handedness_by_range.get(&range_index).copied(),
- ),
- )
+ count,
+ self.view_camera.position,
+ horizontal_fov(&self.view_camera, aspect),
+ )?
+ } else {
+ count
+ };
+ renderer
+ .set_draw_range_index_count(range_index, count)
.map_err(|error| {
format!("configure world draw range {range_index} count: {error}")
})?;
@@ -6130,6 +9632,49 @@ impl StaticVulkanApp {
Ok(())
}
+ fn configure_ambient_effect_draw_ranges(
+ &self,
+ renderer: &mut VulkanSmokeRenderer,
+ ) -> Result<(), String> {
+ for slot in &self.ambient_effect_slots {
+ for (effect_range, _) in ambient_effect_ranges(slot) {
+ let range = self
+ .mesh
+ .draw_ranges
+ .get(effect_range.range_index)
+ .ok_or_else(|| "ambient FX draw range index is out of bounds".to_string())?;
+ let category = self
+ .materials
+ .get(usize::from(range.material_index))
+ .ok_or_else(|| "ambient FX draw range has no MAT0 selector".to_string())?
+ .pipeline_category;
+ renderer
+ .set_draw_range_transparency(effect_range.range_index, true)
+ .map_err(|error| {
+ format!(
+ "configure ambient FX draw range {}: {error}",
+ effect_range.range_index
+ )
+ })?;
+ renderer
+ .set_draw_range_material_category(effect_range.range_index, category)
+ .map_err(|error| format!("configure ambient FX material category: {error}"))?;
+ }
+ }
+ for (range_index, sort_key) in ambient_effect_draw_sort_keys(
+ &self.mesh,
+ &self.ambient_effect_slots,
+ self.view_camera.position,
+ )? {
+ renderer
+ .set_draw_range_sort_key(range_index, sort_key)
+ .map_err(|error| {
+ format!("configure ambient FX draw range {range_index} sort key: {error}")
+ })?;
+ }
+ Ok(())
+ }
+
fn update_world_transparency(
mesh: &VulkanStaticMesh,
materials: &[VulkanStaticMaterial],
@@ -6137,7 +9682,9 @@ impl StaticVulkanApp {
type16_present_ranges: &HashSet<usize>,
world_range_index_counts: &HashMap<usize, u32>,
native_handedness_by_range: &HashMap<usize, i8>,
+ terrain_lod: Option<&terrain_lod::TerrainLodDraws>,
eye_world: [f32; 3],
+ horizontal_fov: f32,
active_materials: &HashMap<usize, ActiveMaterialState>,
renderer: &mut VulkanSmokeRenderer,
) -> Result<(), String> {
@@ -6156,9 +9703,12 @@ impl StaticVulkanApp {
|state| state.diffuse_alpha,
);
let alpha_override = native_msh_diffuse_alpha_override(
- range.batch_flags,
+ mesh,
+ range_index,
type16_present_ranges.contains(&range_index),
- );
+ eye_world,
+ horizontal_fov,
+ )?;
let alpha = alpha_override.unwrap_or(material_alpha);
renderer
.set_draw_range_diffuse_alpha_override(range_index, alpha_override)
@@ -6177,6 +9727,11 @@ impl StaticVulkanApp {
eye_world,
native_handedness_by_range.get(&range_index).copied(),
);
+ let count = if let Some(lod) = terrain_lod {
+ lod.index_count(range_index, count, eye_world, horizontal_fov)?
+ } else {
+ count
+ };
renderer
.set_draw_range_index_count(range_index, count)
.map_err(|error| format!("update world draw range {range_index} count: {error}"))?;
@@ -6207,23 +9762,10 @@ impl StaticVulkanApp {
free_camera.position = safe_position;
}
let view_camera = free_camera.preview_frame(aspect);
- self.free_camera = Some(free_camera);
let camera = view_camera.vulkan_camera();
+ self.free_camera = Some(free_camera);
self.view_camera = view_camera;
self.camera = camera;
- let sort_keys = self.world_draw_sort_keys(view_camera.position)?;
- if let Some(renderer) = self.renderer.as_mut() {
- renderer
- .set_camera(camera)
- .map_err(|error| format!("update free-flight camera: {error}"))?;
- for (range_index, sort_key) in sort_keys {
- renderer
- .set_draw_range_sort_key(range_index, sort_key)
- .map_err(|error| {
- format!("update world draw range {range_index} sort key: {error}")
- })?;
- }
- }
Ok(())
}
@@ -6259,160 +9801,60 @@ impl StaticVulkanApp {
Ok(false)
}
- fn update_material_clock(&mut self, sample: FrameClockSample) {
- self.material_time_ms = sample.time_ms;
+ fn sample_frame_clock(&mut self) -> FrameClockSample {
+ if let Some(target_ms) = self.control_clock_target_ms {
+ let time_ms =
+ control_clock_ms_for_frame(target_ms, self.frames_presented, self.target_frames);
+ self.simulation_elapsed = Duration::from_millis(u64::from(time_ms));
+ self.simulation_time_ms = time_ms;
+ return FrameClockSample {
+ id: SceneSampleId::Replay(self.frames_presented),
+ time_ms,
+ delta_seconds: replay_environment_delta_seconds(
+ self.last_environment_control_clock_ms,
+ time_ms,
+ ),
+ };
+ }
+ self.sample_live_frame_clock_at(Instant::now())
}
- fn update_controlled_component_geometry(
- &mut self,
- sample: FrameClockSample,
- ) -> Result<(), String> {
- let mut pose_changed = false;
- for instance in &mut self.control_animation_instances {
- let pose = static_preview_control_pose_buffer(
- &instance.model,
- &instance.control,
- sample.time_ms,
- &mut instance.state,
- )?;
- let slot = self
- .component_pose_buffers
- .get_mut(instance.object_index)
- .and_then(|buffers| buffers.get_mut(instance.component_index))
- .ok_or_else(|| {
- format!(
- "animated component {} pose slot is outside object {}",
- instance.component_index, instance.object_index
- )
- })?;
- if *slot != pose {
- *slot = pose;
- pose_changed = true;
- }
- }
- if !pose_changed {
- return Ok(());
+ fn pending_or_sample_frame_clock(&mut self) -> FrameClockSample {
+ match self.pending_scene_update.as_ref() {
+ Some(pending) => pending.clock,
+ None => self.sample_frame_clock(),
}
+ }
- // Mounts are recalculated from the sampled hierarchy even when the
- // parent has no visible draw geometry of its own.
- let mounts_changed = refresh_dynamic_component_mounts(
- &self.dynamic_component_mounts,
- &self.component_pose_buffers,
- &mut self.component_mount_poses,
- )?;
- let mut geometry_changed = false;
- let mut shadow_updates = Vec::new();
- let mut updated_shadow_casters = HashSet::new();
- for dynamic in &self.dynamic_component_geometry {
- let pose = self
- .component_pose_buffers
- .get(dynamic.object_index)
- .and_then(|buffers| buffers.get(dynamic.component_index))
- .and_then(Option::as_ref)
- .ok_or_else(|| {
- format!(
- "animated component {} has no current Node38 hierarchy",
- dynamic.component_index
- )
- })?;
- let mount = self
- .component_mount_poses
- .get(dynamic.object_index)
- .and_then(|poses| poses.get(dynamic.component_index))
- .copied()
- .flatten();
- let projected = project_dynamic_component_geometry(dynamic, pose, mount)?;
- if projected.vertices.len() != dynamic.vertex_count {
- return Err(format!(
- "animated component {} changed projected vertex count from {} to {}",
- dynamic.component_index,
- dynamic.vertex_count,
- projected.vertices.len()
- ));
- }
- let end = dynamic
- .vertex_start
- .checked_add(dynamic.vertex_count)
- .ok_or_else(|| "animated component vertex range overflowed".to_string())?;
- let destination = self
- .mesh
- .vertices
- .get_mut(dynamic.vertex_start..end)
- .ok_or_else(|| {
- format!(
- "animated component {} vertex range is outside the shared mesh",
- dynamic.component_index
- )
- })?;
- if destination != projected.vertices.as_slice() {
- destination.copy_from_slice(&projected.vertices);
- geometry_changed = true;
- }
- if let Some(range) = dynamic.shadow_triangle_range.clone() {
- let triangles = static_mesh_shadow_triangles(&projected);
- if triangles.len() != range.len() {
- return Err(format!(
- "animated component {} changed shadow triangle count from {} to {}",
- dynamic.component_index,
- range.len(),
- triangles.len()
- ));
- }
- shadow_updates.push((dynamic.object_index, range, triangles));
- }
- }
- for (object_index, range, triangles) in shadow_updates {
- let Some(caster_index) = self
- .shadow_caster_indices_by_object
- .get(object_index)
- .copied()
- .flatten()
- else {
- continue;
- };
- let caster = self
- .shadow_casters
- .get_mut(caster_index)
- .ok_or_else(|| format!("shadow caster {caster_index} is unavailable"))?;
- let destination = caster.triangles.get_mut(range).ok_or_else(|| {
- format!("animated component shadow range is outside caster {caster_index}")
- })?;
- destination.clone_from_slice(&triangles);
- updated_shadow_casters.insert(caster_index);
+ fn sample_live_frame_clock_at(&mut self, now: Instant) -> FrameClockSample {
+ let elapsed = now.saturating_duration_since(self.simulation_last_tick);
+ self.simulation_last_tick = now;
+ self.simulation_elapsed = self.simulation_elapsed.saturating_add(elapsed);
+ self.simulation_time_ms = self.simulation_elapsed.as_millis() as u32;
+ let sample_id = SceneSampleId::Live(self.simulation_update_generation);
+ self.simulation_update_generation = self.simulation_update_generation.saturating_add(1);
+ FrameClockSample {
+ id: sample_id,
+ time_ms: self.simulation_time_ms,
+ delta_seconds: elapsed.as_secs_f32(),
}
- for caster_index in updated_shadow_casters {
- let baseline = self
- .shadow_sphere_baselines
- .get(caster_index)
- .copied()
- .ok_or_else(|| format!("shadow sphere baseline {caster_index} is unavailable"))?;
- let caster = self
- .shadow_casters
- .get_mut(caster_index)
- .ok_or_else(|| format!("shadow caster {caster_index} is unavailable"))?;
- // shortcut: native animated sphere updates are unverified; derive
- // a conservative envelope from the native startup sphere and all
- // current triangles, never from a prior frame's expanded bounds.
- caster.sphere = shadow_sphere_enclosing_triangles(baseline, &caster.triangles)?;
- }
- if geometry_changed || mounts_changed {
- let sort_keys = self.world_draw_sort_keys(self.view_camera.position)?;
- if let Some(renderer) = self.renderer.as_mut() {
- for (range_index, sort_key) in sort_keys {
- renderer
- .set_draw_range_sort_key(range_index, sort_key)
- .map_err(|error| {
- format!("update animated component sort key {range_index}: {error}")
- })?;
- }
- }
+ }
+
+ fn pause_live_clock_at(&mut self, now: Instant) {
+ self.live_clock_paused_for_zero_extent = true;
+ self.simulation_last_tick = now;
+ }
+
+ fn resume_live_clock_at(&mut self, now: Instant) {
+ if self.live_clock_paused_for_zero_extent {
+ self.simulation_last_tick = now;
+ self.live_clock_paused_for_zero_extent = false;
}
- Ok(())
}
fn update_shadow(
mesh: &mut VulkanStaticMesh,
+ dynamic_vertex_start: usize,
shadow_cache: &mut ShadowPageCache,
shadow_casters: &[ShadowCaster],
shadow_receivers: &[ShadowTriangle],
@@ -6420,12 +9862,14 @@ impl StaticVulkanApp {
shadow_base_vertex: usize,
shadow_base_index: usize,
shadow_material_index: usize,
+ uploaded_shadow_atlas: &mut VulkanStaticTexture,
renderer: &mut VulkanSmokeRenderer,
+ control_point_lights: &[(VulkanPointLight, u32)],
sky_frame: Option<&SkyFrame<'_>>,
environment_frame: Option<&EnvironmentFrame>,
shadow_camera: Option<ShadowCamera>,
) -> Result<ShadowFrameEvidence, String> {
- let lights = shadow_lights(sky_frame, environment_frame, renderer);
+ let lights = shadow_lights(sky_frame, environment_frame, renderer, control_point_lights);
let visible_casters = native_visible_shadow_casters(
shadow_casters,
renderer.frame_uniforms().clip_from_world,
@@ -6449,11 +9893,20 @@ impl StaticVulkanApp {
&frame,
)?;
let atlas = shadow_atlas_texture_from_frame(&frame);
+ 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_material_texture(shadow_material_index, 0, &atlas)
- .map_err(|error| format!("upload projected shadow atlas: {error}"))?;
- renderer
- .update_mesh_geometry(&mesh.vertices, &mesh.indices, &[geometry])
+ .update_mesh_geometry(
+ &mesh.vertices,
+ &mesh.indices,
+ &[geometry],
+ dynamic_vertex_start,
+ shadow_base_index,
+ )
.map_err(|error| format!("upload environment and shadow geometry: {error}"))?;
Ok(ShadowFrameEvidence {
visible_casters: visible_casters.len(),
@@ -6461,90 +9914,549 @@ 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,
+ &self.control_sound_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}"))?;
+ for (id, sound) in self.control_sound_slots.iter_mut().enumerate() {
+ let slot = &mut sound.effect;
+ 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 random = control_effect_random_samples
+ .get(&slot.random_instance_key)
+ .copied()
+ .unwrap_or_default();
+ let Some(matrix) = control_effect_world_from_component_pose_with_random_shift(
+ slot,
+ &self.component_pose_buffers,
+ random.accumulated_shift,
+ ) else {
+ continue;
+ };
+ let Some(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.phase_jitter)) else {
+ continue;
+ };
+ if let Some(event) = sound.command.update(
+ &mut sound.state,
+ phase,
+ matrix,
+ audio.control_sound_is_playing(id),
+ ) {
+ audio
+ .handle_control_sound(id, &event)
+ .map_err(|error| format!("play Control sound {id}: {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,
+ clock.time_ms,
&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,
+ self.terrain_lod.as_ref(),
+ 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,
+ self.dynamic_vertex_start,
&mut self.shadow_cache,
&self.shadow_casters,
&self.shadow_receivers,
@@ -6552,19 +10464,24 @@ 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,
+ self.dynamic_vertex_start,
&mut self.shadow_cache,
&self.shadow_casters,
&self.shadow_receivers,
@@ -6572,149 +10489,22 @@ impl StaticVulkanApp {
self.shadow_base_vertex,
self.shadow_base_index,
self.shadow_material_index,
+ &mut shadow_material.texture,
renderer,
+ &frame.control_point_lights,
None,
None,
- shadow_camera,
+ Some(shadow_camera),
)?;
}
Ok(())
}
- fn refresh_pending_viewport(&mut self, viewport: [f32; 2]) -> Result<(), String> {
- if self.captured_viewport.is_some() {
- return Ok(());
- }
- let Some(mut update) = self.lifecycle.pending.clone() else {
- return Ok(());
- };
- let viewport = [viewport[0].max(1.0), viewport[1].max(1.0)];
- let aspect = viewport[0] / viewport[1];
- let camera = update.camera.with_aspect(update.aspect, aspect);
- self.view_camera = camera;
- self.camera = camera.vulkan_camera();
- if let Some(renderer) = self.renderer.as_mut() {
- renderer
- .set_camera(self.camera)
- .map_err(|error| format!("refresh free-flight camera viewport: {error}"))?;
- }
- let environment_camera = environment_camera_for_preview(&camera, aspect, viewport);
- if let Some(environment) = update.environment.as_mut() {
- environment
- .environment_frame
- .reproject_for_camera(environment_camera);
- }
- update.camera = camera;
- update.viewport = viewport;
- update.aspect = aspect;
- self.lifecycle.pending = Some(update);
- Ok(())
- }
-
- fn apply_pending_scene_update(&mut self) -> Result<(), String> {
- let Some(frame) = self.lifecycle.pending.take() else {
- return Ok(());
- };
- let result = (|| {
- {
- let Some(renderer) = self.renderer.as_mut() else {
- return Err("native Vulkan renderer was not initialized".to_string());
- };
- apply_material_frame_update(
- &frame.material_update,
- &mut self.material_animations,
- renderer,
- )?;
- Self::update_world_transparency(
- &self.mesh,
- &self.materials,
- &self.world_range_indices,
- &self.type16_present_ranges,
- &self.world_range_index_counts,
- &self.native_handedness_by_range,
- frame.camera.position,
- &frame.material_update.active_materials,
- renderer,
- )?;
- if let Some(material_update) = frame.environment_material_update.as_ref() {
- let environment_gpu = self.environment_gpu.as_mut().ok_or_else(|| {
- "sampled environment material state lost its owner".to_string()
- })?;
- apply_material_frame_update(
- material_update,
- &mut environment_gpu.material_animations,
- renderer,
- )?;
- }
- }
- self.apply_environment_view(
- frame.environment.as_ref(),
- frame.environment_material_update.as_ref(),
- frame.camera,
- frame.viewport,
- )
- })();
- self.lifecycle.pending = Some(frame);
- result
- }
-
- fn sample_frame_clock(&mut self) -> FrameClockSample {
- if let Some(target_ms) = self.control_clock_target_ms {
- return self.lifecycle.sample_replay(target_ms, self.target_frames);
- }
- self.sample_live_frame_clock_at(Instant::now())
- }
-
- fn sample_live_frame_clock_at(&mut self, now: Instant) -> FrameClockSample {
- if let Some(pending) = self.lifecycle.pending.as_ref() {
- return pending.sample;
- }
- let elapsed = now.saturating_duration_since(self.last_tick);
- self.last_tick = now;
- self.simulation_elapsed = self.simulation_elapsed.saturating_add(elapsed);
- FrameClockSample {
- id: SceneSampleId::Live(self.lifecycle.frames_presented),
- time_ms: self.simulation_elapsed.as_millis() as u32,
- delta_seconds: elapsed.as_secs_f32(),
- }
- }
-
- fn prepare_scene_update(
- &mut self,
- sample: FrameClockSample,
- viewport: [f32; 2],
- ) -> Result<SceneFrameUpdate, String> {
- let viewport = [viewport[0].max(1.0), viewport[1].max(1.0)];
- let aspect = viewport[0] / viewport[1];
- self.update_material_clock(sample);
- self.update_controlled_component_geometry(sample)?;
- self.update_free_camera(sample.delta_seconds, viewport)?;
- let material_update = sample_material_animations(
- &mut self.material_animations,
- &self.materials,
- self.material_time_ms,
- )?;
- let environment_material_update = self
- .environment_gpu
- .as_mut()
- .map(|environment_gpu| {
- sample_material_animations(
- &mut environment_gpu.material_animations,
- &self.materials,
- self.material_time_ms,
- )
- })
- .transpose()?;
- let environment = self.update_environment(sample, viewport)?;
- Ok(SceneFrameUpdate {
- sample,
- camera: self.view_camera,
- viewport,
- aspect,
- material_update,
- environment_material_update,
- environment,
- })
+ fn record_presented_scene_frame(&mut self) -> u64 {
+ let frame_index = self.frames_presented;
+ self.frames_presented = self.frames_presented.saturating_add(1);
+ self.pending_scene_update = None;
+ frame_index
}
fn set_mouse_look(&mut self, active: bool) {
@@ -6805,6 +10595,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| {
@@ -6814,15 +10611,34 @@ impl StaticVulkanApp {
)
},
);
+ eprintln!(
+ "render_geometry_upload vertex_bytes={} index_bytes={} full_upload_bytes={}",
+ report.renderer_report.mesh_vertex_upload_bytes,
+ report.renderer_report.mesh_index_upload_bytes,
+ report.renderer_report.mesh_full_upload_bytes,
+ );
+ eprintln!(
+ "render_texture_upload slots={} initial_images={} detached_images={}",
+ self.materials.len() * 4,
+ report.renderer_report.initial_texture_image_count,
+ report.renderer_report.texture_detach_count,
+ );
+ eprintln!(
+ "render_pipeline_cache pipelines_created={}",
+ report.renderer_report.pipeline_creation_count
+ );
self.output = Some(format!(
- "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, captured_viewport_crop={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}",
+ "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, captured_viewport_crop={}, control_clock_ms={}, control_clock_mode={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, readback_sequence={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}",
self.mission,
- self.lifecycle.frames_presented,
+ self.frames_presented,
self.object_count,
self.mesh_components,
self.terrain_components,
self.camera_mode,
captured_crop,
+ self.control_clock_target_ms
+ .map_or_else(|| "realtime".to_string(), |clock| clock.to_string()),
+ control_clock_mode_label(self.control_clock_target_ms),
self.materials.len(),
self.environment_primitive_count,
self.shadow_last_evidence.visible_casters,
@@ -6832,6 +10648,9 @@ impl StaticVulkanApp {
report.renderer_report.swapchain_image_format,
readback_format,
readback_path.as_deref().unwrap_or("none"),
+ self.readback_sequence_out
+ .as_deref()
+ .map_or_else(|| "none".to_string(), |path| path.display().to_string()),
elapsed_ms(self.startup_timings.mission_assets),
elapsed_ms(self.startup_timings.terrain_materials),
elapsed_ms(self.startup_timings.sky),
@@ -6948,7 +10767,15 @@ impl ApplicationHandler for StaticVulkanApp {
event_loop.exit();
return;
}
- if let Err(error) = self.configure_world_draw_ranges(&mut renderer) {
+ if let Err(error) = self.configure_world_draw_ranges(
+ &mut renderer,
+ size.width.max(1) as f32 / size.height.max(1) as f32,
+ ) {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
+ }
+ if let Err(error) = self.configure_ambient_effect_draw_ranges(&mut renderer) {
self.error = Some(error);
event_loop.exit();
return;
@@ -6972,13 +10799,28 @@ impl ApplicationHandler for StaticVulkanApp {
}
}
self.startup_timings.gpu_initialization = gpu_initialization_started.elapsed();
- renderer.set_readback_enabled(self.readback_out.is_some());
+ renderer.set_readback_enabled(
+ self.readback_out.is_some() || self.readback_sequence_out.is_some(),
+ );
self.window_id = Some(window.id());
self.window = Some(window);
self.renderer = Some(renderer);
- self.last_tick = Instant::now();
- self.simulation_elapsed = Duration::ZERO;
- self.material_time_ms = 0;
+ self.simulation_last_tick = Instant::now();
+ self.live_clock_paused_for_zero_extent = false;
+ self.pending_scene_update = None;
+ let drawable_size = self
+ .window
+ .as_ref()
+ .map(Window::inner_size)
+ .unwrap_or_default();
+ if let Err(error) = self.update_free_camera(
+ 0.0,
+ [drawable_size.width as f32, drawable_size.height as f32],
+ ) {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
+ }
self.schedule_next_redraw();
}
@@ -7049,25 +10891,27 @@ impl ApplicationHandler for StaticVulkanApp {
}
if drawable_size.width == 0 || drawable_size.height == 0 {
event_loop.set_control_flow(ControlFlow::Wait);
+ self.pause_live_clock_at(Instant::now());
} else {
event_loop.set_control_flow(ControlFlow::Poll);
+ self.resume_live_clock_at(Instant::now());
+ // The pending simulation sample is reused; its view and
+ // viewport-dependent geometry refresh before the retry.
}
}
WindowEvent::RedrawRequested => {
- if self.lifecycle.pending.is_none() {
+ if self.pending_scene_update.is_none() {
return;
}
- let drawable_available = self.window.as_ref().is_some_and(|window| {
- let size = window.inner_size();
- size.width != 0 && size.height != 0
- });
- if drawable_available {
- if let Err(error) = self.apply_pending_scene_update() {
- self.error = Some(error);
- event_loop.exit();
- return;
- }
+ if let Err(error) = self.render_scene_update() {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
}
+ let diagnostic_clock_ms = self
+ .pending_scene_update
+ .as_ref()
+ .map_or(self.simulation_time_ms, |frame| frame.clock.time_ms);
let outcome = {
let Some(renderer) = self.renderer.as_mut() else {
self.error = Some("native Vulkan renderer was not initialized".to_string());
@@ -7078,28 +10922,50 @@ impl ApplicationHandler for StaticVulkanApp {
};
match outcome {
Ok(VulkanSmokeFrameOutcome::Presented) => {
- self.lifecycle.record_presented();
- }
- Ok(VulkanSmokeFrameOutcome::Recreated) => {
- if let Some((width, height)) = self
- .renderer
- .as_ref()
- .map(|renderer| renderer.report().swapchain_extent)
- {
- if width != 0 && height != 0 {
- if let Err(error) =
- self.refresh_pending_viewport([width as f32, height as f32])
- {
- self.error = Some(error);
+ let frame_index = self.record_presented_scene_frame();
+ if let Some(directory) = self.readback_sequence_out.as_deref() {
+ let artifact = match self
+ .renderer
+ .as_mut()
+ .expect("renderer presented a frame")
+ .readback_last_submitted_frame()
+ {
+ Ok(Some(artifact)) => artifact,
+ Ok(None) => {
+ self.error = Some(
+ "native Vulkan renderer produced no synchronized sequence readback"
+ .to_string(),
+ );
+ event_loop.exit();
+ return;
+ }
+ Err(error) => {
+ self.error =
+ Some(format!("read back frame {frame_index}: {error}"));
event_loop.exit();
return;
}
+ };
+ if let Err(error) = write_readback_sequence_frame(
+ directory,
+ frame_index,
+ diagnostic_clock_ms,
+ &artifact,
+ ) {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
}
}
}
+ Ok(VulkanSmokeFrameOutcome::Recreated) => {}
Ok(VulkanSmokeFrameOutcome::ZeroExtent) => {
- if !drawable_available {
+ if self.window.as_ref().is_some_and(|window| {
+ let size = window.inner_size();
+ size.width == 0 || size.height == 0
+ }) {
event_loop.set_control_flow(ControlFlow::Wait);
+ self.pause_live_clock_at(Instant::now());
}
}
Err(err) => {
@@ -7108,11 +10974,8 @@ impl ApplicationHandler for StaticVulkanApp {
return;
}
}
- if self.target_frames != 0 && self.lifecycle.frames_presented >= self.target_frames
- {
+ if self.target_frames != 0 && self.frames_presented >= self.target_frames {
self.complete(event_loop);
- } else if drawable_available {
- self.schedule_next_redraw();
}
}
_ => {}
@@ -7128,28 +10991,21 @@ impl ApplicationHandler for StaticVulkanApp {
};
let size = window.inner_size();
if size.width == 0 || size.height == 0 {
+ // Wait for a resize instead of spinning simulation updates while
+ // the window has no drawable surface.
event_loop.set_control_flow(ControlFlow::Wait);
+ self.pause_live_clock_at(Instant::now());
return;
}
event_loop.set_control_flow(ControlFlow::Poll);
- if self.lifecycle.pending.is_none() {
- let viewport = self
- .captured_viewport
- .map_or([size.width as f32, size.height as f32], |captured| {
- [captured.extent[0] as f32, captured.extent[1] as f32]
- });
- let sample = self.sample_frame_clock();
- let update = match self.prepare_scene_update(sample, viewport) {
- Ok(update) => update,
- Err(error) => {
- self.error = Some(error);
- event_loop.exit();
- return;
- }
- };
- if !self.lifecycle.prepare(update) {
- return;
- }
+ self.resume_live_clock_at(Instant::now());
+ let clock = self.pending_or_sample_frame_clock();
+ if let Err(error) =
+ self.prepare_scene_update(clock, [size.width as f32, size.height as f32])
+ {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
}
self.schedule_next_redraw();
}
@@ -7164,6 +11020,7 @@ struct Args {
control_clock_ms: Option<u32>,
validation: bool,
readback_out: Option<PathBuf>,
+ readback_sequence_out: Option<PathBuf>,
preview_roots: NonZeroUsize,
legacy_camera_capture: Option<PathBuf>,
static_animation_frame: Option<u16>,
@@ -7182,6 +11039,7 @@ impl Args {
let mut control_clock_ms = None;
let mut validation = false;
let mut readback_out = None;
+ let mut readback_sequence_out = None;
// A native static-Vulkan invocation is the usable mission preview, so
// its default scope covers every root. `PreviewRoots` is clamped by the
// runtime to the decoded mission length; an explicit --preview-roots N
@@ -7245,6 +11103,12 @@ impl Args {
.ok_or_else(|| "--readback-out requires a path".to_string())?,
);
}
+ "--readback-sequence-out" => {
+ readback_sequence_out =
+ Some(iter.next().map(PathBuf::from).ok_or_else(|| {
+ "--readback-sequence-out requires a path".to_string()
+ })?);
+ }
"--preview-roots" => {
preview_roots = iter
.next()
@@ -7289,6 +11153,12 @@ impl Args {
.to_string(),
);
}
+ if readback_sequence_out.is_some() && control_clock_ms.is_none() {
+ return Err(
+ "--readback-sequence-out requires --control-clock-ms for a reproducible sequence"
+ .to_string(),
+ );
+ }
Ok(Self {
root,
mission,
@@ -7297,6 +11167,7 @@ impl Args {
control_clock_ms,
validation,
readback_out,
+ readback_sequence_out,
preview_roots,
legacy_camera_capture,
static_animation_frame,
@@ -7305,365 +11176,368 @@ impl Args {
}
}
-#[derive(Clone, Debug, Deserialize, PartialEq)]
-struct LegacyCameraCapture {
- schema: String,
- render_input_usable: Option<bool>,
- selector0_words: [u32; 16],
- parented_camera: Option<bool>,
- effective_world_matrix_space: Option<String>,
- effective_world_matrix_words: Option<[u32; 16]>,
- viewport: [i32; 4],
- near_plane: f32,
- far_plane: f32,
- field_of_view_radians: f32,
- atmosphere_seconds: Option<f32>,
+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>] [--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()
}
-fn selected_atmosphere_seconds(
- command_line: Option<f32>,
- capture: Option<&LegacyCameraCapture>,
-) -> Option<f32> {
- command_line.or_else(|| capture.and_then(|capture| capture.atmosphere_seconds))
-}
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_animation::{AnimKey24, Pose};
+ use fparkan_assets::{PreparedControlTimelineRow, PreparedControlTypedRecord};
+ use fparkan_fx::environment::{WeatherInput, WeatherState};
+ use fparkan_msh::{Batch, ModelAnimation, Slot};
+ use fparkan_path::{normalize_relative, PathPolicy};
+ use fparkan_resource::{resource_name, ResourceKey};
-impl LegacyCameraCapture {
- fn preview_matrix_words(&self) -> Result<[u32; 16], String> {
- match (
- self.effective_world_matrix_space.as_deref(),
- self.effective_world_matrix_words,
- ) {
- (Some("world"), Some(words)) => Ok(words),
- (Some(_), _) => Err(
- "legacy camera effective_world_matrix_space must be 'world'".to_string(),
- ),
- (None, Some(_)) => Err(
- "legacy camera effective_world_matrix_words requires an explicit world-space marker"
- .to_string(),
- ),
- (None, None) if self.parented_camera == Some(true) => Err(
- "parented legacy camera requires an effective world-space matrix".to_string(),
- ),
- (None, None) => Ok(self.selector0_words),
- }
- }
-
- fn captured_viewport(&self) -> Result<CapturedViewport, String> {
- let width = self.viewport[2]
- .checked_sub(self.viewport[0])
- .filter(|width| *width > 0)
- .ok_or_else(|| "legacy camera capture has an invalid viewport width".to_string())?;
- let height = self.viewport[3]
- .checked_sub(self.viewport[1])
- .filter(|height| *height > 0)
- .ok_or_else(|| "legacy camera capture has an invalid viewport height".to_string())?;
- Ok(CapturedViewport {
- rect: self.viewport,
- extent: [
- u32::try_from(width)
- .map_err(|_| "legacy camera viewport width exceeds u32".to_string())?,
- u32::try_from(height)
- .map_err(|_| "legacy camera viewport height exceeds u32".to_string())?,
- ],
- })
+ 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 preview_camera_frame(&self) -> Result<PreviewCameraFrame, String> {
- let frame = PreviewCameraFrame::from_legacy_d3d7(
- RawCameraTransform {
- words: self.preview_matrix_words()?,
- },
- LegacyD3d7Projection {
- viewport: self.viewport,
- near_plane: self.near_plane,
- far_plane: self.far_plane,
- field_of_view_radians: self.field_of_view_radians,
- },
- )
- .ok_or_else(|| "legacy camera capture contains an invalid D3D7 camera".to_string())?;
- self.captured_viewport()?;
- Ok(frame)
+ 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 load_legacy_camera_capture(path: &std::path::Path) -> Result<LegacyCameraCapture, String> {
- let bytes = std::fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
- parse_legacy_camera_capture(&bytes).map_err(|err| format!("{}: {err}", path.display()))
-}
-
-fn parse_legacy_camera_capture(bytes: &[u8]) -> Result<LegacyCameraCapture, String> {
- // Windows PowerShell 5.1 writes redirected text as UTF-16LE with a BOM.
- // `capture-original-camera.ps1` intentionally emits plain JSON, so accept
- // that normal hand-off format as well as UTF-8 without making the caller
- // re-encode a read-only capture file.
- let json = decode_legacy_camera_capture_json(bytes)?;
- let capture: LegacyCameraCapture =
- serde_json::from_str(&json).map_err(|err| format!("invalid legacy camera JSON: {err}"))?;
- if capture.schema != "fparkan-legacy-camera-v1" {
- return Err("unsupported legacy camera capture schema".to_string());
- }
- if capture.effective_world_matrix_space.is_some()
- != capture.effective_world_matrix_words.is_some()
- {
- return Err(
- "effective_world_matrix_space and effective_world_matrix_words must be supplied together"
- .to_string(),
- );
- }
- if capture.render_input_usable == Some(false) {
- return Err("legacy camera capture is marked render_input_usable=false".to_string());
+ 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)),
+ ]
}
- if capture
- .atmosphere_seconds
- .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0)
- {
- return Err(
- "legacy camera capture atmosphere_seconds must be finite and non-negative".to_string(),
- );
+
+ 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
}
- capture.preview_camera_frame()?;
- Ok(capture)
-}
-fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> {
- let decode_utf16 = |words: Vec<u16>| {
- String::from_utf16(&words)
- .map_err(|err| format!("invalid UTF-16 legacy camera JSON: {err}"))
- };
- match bytes {
- [0xff, 0xfe, rest @ ..] => {
- let chunks = rest.chunks_exact(2);
- if !chunks.remainder().is_empty() {
- return Err("invalid UTF-16LE legacy camera JSON length".to_string());
- }
- decode_utf16(
- chunks
- .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
- .collect(),
- )
- }
- [0xfe, 0xff, rest @ ..] => {
- let chunks = rest.chunks_exact(2);
- if !chunks.remainder().is_empty() {
- return Err("invalid UTF-16BE legacy camera JSON length".to_string());
- }
- decode_utf16(
- chunks
- .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
- .collect(),
- )
+ #[test]
+ fn native_msh_cull_mode_matches_legacy_and_free_flight_camera_projection() {
+ let identity = RawCameraTransform {
+ words: [
+ 1.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 1.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 1.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 1.0_f32.to_bits(),
+ ],
+ };
+ let projection = LegacyD3d7Projection {
+ viewport: [0, 0, 1280, 1024],
+ near_plane: 0.5,
+ far_plane: 700.0,
+ field_of_view_radians: 1.3,
+ };
+ let legacy = PreviewCameraFrame::from_legacy_d3d7(identity, projection)
+ .expect("valid legacy camera projection");
+ let free_flight = FreeFlightCamera {
+ position: legacy.position,
+ yaw: 0.0,
+ pitch: 0.0,
+ horizontal_fov: 1.3,
+ near_plane: 0.5,
+ far_plane: 700.0,
+ move_speed: 1.0,
+ ground_clearance: 0.0,
}
- [0xef, 0xbb, 0xbf, rest @ ..] => std::str::from_utf8(rest)
- .map(str::to_owned)
- .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")),
- _ => std::str::from_utf8(bytes)
- .map(str::to_owned)
- .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")),
- }
-}
+ .preview_frame(1280.0 / 1024.0);
-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()
-}
+ 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);
-#[cfg(test)]
-mod tests {
- use super::*;
- use fparkan_fx::environment::{WeatherInput, WeatherState};
- use fparkan_material::{MaterialAnimationBlock, MaterialKey};
+ 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);
- fn material_test_phase(parameters: [u8; 18]) -> MaterialPhase {
- MaterialPhase {
- parameters,
- texture_raw: [0; 16],
+ 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);
}
}
- 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),
- ],
- 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],
+ #[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],
};
- 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])]),
- }),
- }
+ 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 packet_test_materials() -> Vec<VulkanStaticMaterial> {
- let mut materials = Vec::new();
- for _ in 0..6 {
- append_environment_gradient_material(&mut materials).expect("test material");
- }
- materials[4].pipeline_category = 1;
- materials[5].pipeline_category = 3;
- materials
+ #[test]
+ fn native_msh_4000_samples_only_first_triangle_and_raw_two_sided_bypasses_it() {
+ let front = native_front_triangle();
+ let back = front.map(|point| [-point[0], point[1], point[2]]);
+ let front_then_back = [front[0], front[1], front[2], back[0], back[1], back[2]];
+ let (mesh, range) = facing_mesh(0x4000, &front_then_back);
+ assert!(native_msh_first_triangle_visible(
+ &mesh, &range, [0.0; 3], 1
+ ));
+
+ let back_then_front = [back[0], back[1], back[2], front[0], front[1], front[2]];
+ let (mesh, range) = facing_mesh(0x4000, &back_then_front);
+ assert!(!native_msh_first_triangle_visible(
+ &mesh, &range, [0.0; 3], 1
+ ));
+
+ let (mesh, range) = facing_mesh(0x4002, &back_then_front);
+ assert!(native_msh_first_triangle_visible(
+ &mesh, &range, [0.0; 3], 1
+ ));
+ assert_eq!(
+ native_msh_cull_mode(range.batch_flags, 1),
+ LegacyCullMode::Disabled
+ );
}
#[test]
- fn 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 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!(
- packet.range_selections,
- vec![MaterialRangeSelection {
- range_index: 7,
- material_index: 5,
- pipeline_category: 3,
- }]
+ world_draw_range_index_count(&mesh, &range, original_count, 0.0, [0.0; 3], Some(1)),
+ 0
);
assert_eq!(
- packet.active_materials.get(&7),
- Some(&ActiveMaterialState {
- material_index: 5,
- diffuse_alpha: 0.75,
- })
+ world_draw_range_index_count(
+ &mesh,
+ &range,
+ original_count,
+ 1.0,
+ [0.0, 0.0, -2.0],
+ Some(1),
+ ),
+ 0
);
- let properties = packet.properties[0];
assert_eq!(
- (properties.diffuse_alpha, properties.overlay_alpha),
- (0.75, 0.25)
+ world_draw_range_index_count(&mesh, &range, original_count, 1.0, [0.0; 3], Some(1)),
+ original_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,
+ 1.0,
+ [0.0, 0.0, -2.0],
+ None,
+ ),
+ original_count
);
- 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]),
+ }
+
+ #[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!(
- (properties.specular_rgb, properties.specular_power),
- ([100.0 / 255.0, 150.0 / 255.0, 200.0 / 255.0], 9)
- );
- assert_eq!(
- (properties.overlay_specular_rgb, properties.overlay_power),
- (Some([40.0 / 255.0, 80.0 / 255.0, 120.0 / 255.0]), Some(5))
- );
+ };
+
+ assert_eq!(update(&range, [0.0; 3], &mut alpha_override), 0);
+ range.batch_flags = 0x0108;
+ assert_eq!(update(&range, [0.0; 3], &mut alpha_override), 0);
assert_eq!(
- properties.uv_transforms,
- [
- [0.5, 0.25, 0.25, 0.5],
- [0.1, 0.2, 0.3, 0.4],
- [0.3, 0.4, 0.5, 0.6],
- [0.6, 0.7, 0.8, 0.9],
- ]
+ update(&range, [150.0; 3], &mut alpha_override),
+ original_count
);
- assert_eq!(
- packet.stage_phases,
- vec![MaterialStagePhaseUpdate {
- binding_index: 0,
- material_index: 5,
- phases: [1, 0, 1],
- }]
- );
- assert_eq!(bindings[0].base.random_state, 2_531_011);
- }
-
- fn control_pose_test_model() -> ModelAsset {
- let mut nodes = vec![u8::MAX; 38];
- nodes[..2].copy_from_slice(&[0, 0]);
- nodes[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
- nodes[4..6].copy_from_slice(&0_u16.to_le_bytes());
- nodes[6..8].copy_from_slice(&1_u16.to_le_bytes());
+ range.batch_flags = 0x0148;
+ assert_eq!(update(&range, [150.0; 3], &mut alpha_override), 0);
+ assert_eq!(original_count, 3);
+ }
+
+ fn identity_matrix() -> [[f32; 4]; 4] {
+ [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ]
+ }
+
+ fn node38_fixture(parent: u16, map_start: u16, fallback: u16, drawable: bool) -> Vec<u8> {
+ let mut node = Vec::with_capacity(38);
+ node.extend_from_slice(&[0, 0]);
+ node.extend_from_slice(&parent.to_le_bytes());
+ node.extend_from_slice(&map_start.to_le_bytes());
+ node.extend_from_slice(&fallback.to_le_bytes());
+ for slot in 0..15 {
+ let value = if drawable && slot == 0 { 0 } else { u16::MAX };
+ node.extend_from_slice(&value.to_le_bytes());
+ }
+ node
+ }
+
+ fn animated_socket_model() -> ModelAsset {
+ let mut nodes = node38_fixture(u16::MAX, 0, 1, true);
+ nodes.extend(node38_fixture(0, u16::MAX, 2, false));
ModelAsset {
node_stride: 38,
- node_count: 1,
+ node_count: 2,
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
- bounding_sphere_radius: 1.0,
- slots: vec![],
- positions: vec![],
- normals: None,
+ bounding_sphere_radius: 2.0,
+ slots: vec![Slot {
+ tri_start: 0,
+ tri_count: 1,
+ batch_start: 0,
+ batch_count: 1,
+ aabb_min: [-1.0; 3],
+ aabb_max: [1.0; 3],
+ sphere_center: [0.0; 3],
+ sphere_radius: 1.0,
+ opaque: [0; 5],
+ }],
+ positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
+ normals: Some(vec![[127, 0, 0, 0]; 3]),
uv0: None,
uv1: None,
type16_present: false,
- indices: vec![],
- batches: vec![],
+ indices: vec![0, 1, 2],
+ batches: vec![Batch {
+ batch_flags: 0,
+ material_index_hi: 0,
+ material_index: 0,
+ lightmap_index: 0,
+ local_batch_index: 0,
+ index_count: 3,
+ index_start: 0,
+ vertex_count: 3,
+ base_vertex: 0,
+ }],
triangle_descriptors: None,
node_names: None,
- animation: Some(fparkan_msh::ModelAnimation {
+ animation: Some(ModelAnimation {
keys: vec![
- fparkan_animation::AnimKey24 {
+ AnimKey24 {
time: AnimationTime(0.0),
- pose: fparkan_animation::Pose::default(),
+ pose: Pose::default(),
},
- fparkan_animation::AnimKey24 {
+ AnimKey24 {
time: AnimationTime(1.0),
- pose: fparkan_animation::Pose {
+ pose: Pose {
+ translation: [0.0; 3],
+ rotation: [
+ 0.0,
+ 0.0,
+ std::f32::consts::FRAC_1_SQRT_2,
+ std::f32::consts::FRAC_1_SQRT_2,
+ ],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose {
translation: [0.0, 2.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
@@ -7675,101 +11549,54 @@ mod tests {
}
}
- fn control_pose_drawable_test_model() -> ModelAsset {
- let mut model = control_pose_test_model();
- model.nodes_raw[8..10].copy_from_slice(&0_u16.to_le_bytes());
- model.slots = vec![fparkan_msh::Slot {
- tri_start: 0,
- tri_count: 1,
- batch_start: 0,
- batch_count: 1,
- aabb_min: [0.0; 3],
- aabb_max: [1.0; 3],
- sphere_center: [0.0; 3],
- sphere_radius: 1.0,
- opaque: [0; 5],
- }];
- model.positions = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
- model.normals = Some(vec![[0, 0, 127, 0]; 3]);
- model.uv0 = Some(vec![[0, 0]; 3]);
- model.uv1 = None;
- model.indices = vec![0, 1, 2];
- model.batches = vec![fparkan_msh::Batch {
- batch_flags: 0,
- material_index_hi: 0,
- material_index: 0,
- lightmap_index: u8::MAX,
- local_batch_index: 0,
- index_count: 3,
- index_start: 0,
- vertex_count: 3,
- base_vertex: 0,
- }];
- model
- }
-
- fn control_pose_test_control() -> PreparedComponentControl {
- let mut fixed_words = [0; 44];
- fixed_words[2] = 0;
- fixed_words[6] = u32::MAX;
- fixed_words[9] = 1.0_f32.to_bits();
- fixed_words[10] = 1.0_f32.to_bits();
- let archive = || {
- fparkan_path::normalize_relative(b"test.rlb", fparkan_path::PathPolicy::HostCompatible)
- .expect("test archive path")
- };
- PreparedComponentControl {
- source: fparkan_resource::ResourceKey {
- archive: archive(),
- name: ResourceName(b"animated.ctl".to_vec()),
- type_id: None,
- },
- model_node_count: 1,
- native_profile_words: [0; 27],
- bindings: vec![],
- timeline_rows: vec![fparkan_assets::PreparedControlTimelineRow {
- row_index: 0,
- node_index: 0,
- frame_a: 0.0,
- frame_b: 1.0,
- initial_blend: 0.0,
- raw_parameters: [u32::MAX, u32::MAX, 1.0_f32.to_bits(), 0],
- flags: 0,
+ fn static_child_model() -> ModelAsset {
+ let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, false);
+ nodes.extend(node38_fixture(0, u16::MAX, 0, true));
+ ModelAsset {
+ node_stride: 38,
+ node_count: 2,
+ nodes_raw: nodes,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 1.0,
+ slots: vec![Slot {
+ tri_start: 0,
+ tri_count: 1,
+ batch_start: 0,
+ batch_count: 1,
+ aabb_min: [-1.0; 3],
+ aabb_max: [1.0; 3],
+ sphere_center: [0.0; 3],
+ sphere_radius: 1.0,
+ opaque: [0; 5],
}],
- typed_records: vec![fparkan_assets::PreparedControlTypedRecord {
- tag: 21,
- fixed_words,
- values: vec![0],
- string_bytes: vec![],
- gate_provider: PreparedControlGateProvider::Resolved {
- key: 0,
- ndp_source: fparkan_resource::ResourceKey {
- archive: archive(),
- name: ResourceName(b"animated.ndp".to_vec()),
- type_id: None,
- },
- row_count: 1,
- initial_type1_value: 1.0,
- },
+ positions: vec![[0.0, 0.0, 0.0]; 3],
+ normals: Some(vec![[0, 0, 127, 0]; 3]),
+ uv0: None,
+ uv1: None,
+ type16_present: false,
+ indices: vec![0, 1, 2],
+ batches: vec![Batch {
+ batch_flags: 0,
+ material_index_hi: 0,
+ material_index: 0,
+ lightmap_index: 0,
+ local_batch_index: 0,
+ index_count: 3,
+ index_start: 0,
+ vertex_count: 3,
+ base_vertex: 0,
}],
- node_data: None,
- effect_section: fparkan_assets::PreparedControlEffectSection::Absent,
- initial_effect_group: None,
- point_list: None,
- }
- }
-
- fn node38_fixture(parent: u16, map_start: u16, fallback: u16, drawable: bool) -> Vec<u8> {
- let mut node = Vec::with_capacity(38);
- node.extend_from_slice(&[0, 0]);
- node.extend_from_slice(&parent.to_le_bytes());
- node.extend_from_slice(&map_start.to_le_bytes());
- node.extend_from_slice(&fallback.to_le_bytes());
- for slot in 0..15 {
- let value = if drawable && slot == 0 { 0 } else { u16::MAX };
- node.extend_from_slice(&value.to_le_bytes());
+ triangle_descriptors: None,
+ node_names: None,
+ animation: Some(ModelAnimation {
+ keys: vec![AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose::default(),
+ }],
+ frame_map: vec![],
+ frame_count: 0,
+ }),
}
- node
}
fn collision_fixture_model(root_drawable: bool) -> ModelAsset {
@@ -7781,7 +11608,7 @@ mod tests {
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 3.0,
- slots: vec![fparkan_msh::Slot {
+ slots: vec![Slot {
tri_start: 0,
tri_count: 1,
batch_start: 0,
@@ -7798,7 +11625,7 @@ mod tests {
uv1: None,
type16_present: false,
indices: vec![0, 1, 2],
- batches: vec![fparkan_msh::Batch {
+ batches: vec![Batch {
batch_flags: 0,
material_index_hi: 0,
material_index: 0,
@@ -7815,15 +11642,15 @@ mod tests {
normal: [0, 32767, 0],
}]),
node_names: None,
- animation: Some(fparkan_msh::ModelAnimation {
+ animation: Some(ModelAnimation {
keys: vec![
- fparkan_animation::AnimKey24 {
+ AnimKey24 {
time: AnimationTime(0.0),
- pose: fparkan_animation::Pose::default(),
+ pose: Pose::default(),
},
- fparkan_animation::AnimKey24 {
+ AnimKey24 {
time: AnimationTime(1.0),
- pose: fparkan_animation::Pose {
+ pose: Pose {
translation: [20.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
@@ -7835,6 +11662,78 @@ mod tests {
}
}
+ #[test]
+ fn mounted_static_building_binding_matches_renderer_and_hides_root_node() {
+ let model = collision_fixture_model(true);
+ let binding = building_collision_binding(
+ 4,
+ 5,
+ "static fixture model".to_string(),
+ Arc::new(model.clone()),
+ LegacyIron3dEulerTransform {
+ translation: [13.0, -4.0, 9.0],
+ orientation_radians: [0.4, -0.3, 0.7],
+ },
+ [2.0, 3.0, 0.5],
+ true,
+ )
+ .expect("static building geometry still creates a collision binding");
+ assert_eq!(
+ binding.selected_nodes,
+ vec![(fparkan_msh::NodeId(1), fparkan_msh::SlotId(0))]
+ );
+
+ let root = VulkanNodePose {
+ translation: [1.0, 2.0, 3.0],
+ rotation: [0.0, 0.0, 0.29552022, 0.9553365],
+ };
+ let moving_node = compose_unit_pose(
+ root,
+ VulkanNodePose {
+ translation: [2.0, -1.0, 0.5],
+ rotation: [0.0, 0.19866933, 0.0, 0.9800666],
+ },
+ );
+ let hierarchy = NodePoseBuffer {
+ poses: [root, moving_node]
+ .into_iter()
+ .map(|pose| Pose {
+ translation: pose.translation,
+ rotation: pose.rotation,
+ })
+ .collect(),
+ };
+ let mount = VulkanNodePose {
+ translation: [5.0, 7.0, 11.0],
+ rotation: [0.0, 0.0, 0.38268343, 0.9238795],
+ };
+ let nodes = building_collision_nodes(&binding, Some(&hierarchy), Some(mount))
+ .expect("mounted collision matrices");
+ assert_eq!(nodes.len(), 1);
+ assert_eq!(nodes[0].node, fparkan_msh::NodeId(1));
+
+ let projected = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
+ &model,
+ binding.root_transform,
+ binding.owner_scale,
+ &hierarchy,
+ mount,
+ )
+ .expect("mounted renderer projection");
+ assert_eq!(projected.vertices.len(), 3);
+ for (actual, source) in projected.vertices.iter().zip(&model.positions) {
+ let matrix = nodes[0].world_from_node;
+ let expected = [
+ matrix[0] * source[0] + matrix[1] * source[1] + matrix[2] * source[2] + matrix[3],
+ matrix[4] * source[0] + matrix[5] * source[1] + matrix[6] * source[2] + matrix[7],
+ matrix[8] * source[0] + matrix[9] * source[1] + matrix[10] * source[2] + matrix[11],
+ ];
+ for axis in 0..3 {
+ assert!((actual.position[axis] - expected[axis]).abs() < 1.0e-4);
+ }
+ }
+ }
+
fn doorway_collision_model() -> ModelAsset {
let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, false);
nodes.extend(node38_fixture(0, u16::MAX, 0, true));
@@ -7844,7 +11743,7 @@ mod tests {
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 5.0,
- slots: vec![fparkan_msh::Slot {
+ slots: vec![Slot {
tri_start: 0,
tri_count: 4,
batch_start: 0,
@@ -7870,7 +11769,7 @@ mod tests {
uv1: None,
type16_present: false,
indices: vec![0, 3, 2, 0, 2, 1, 4, 7, 6, 4, 6, 5],
- batches: vec![fparkan_msh::Batch {
+ batches: vec![Batch {
batch_flags: 0,
material_index_hi: 0,
material_index: 0,
@@ -7891,10 +11790,10 @@ mod tests {
.collect(),
),
node_names: None,
- animation: Some(fparkan_msh::ModelAnimation {
- keys: vec![fparkan_animation::AnimKey24 {
+ animation: Some(ModelAnimation {
+ keys: vec![AnimKey24 {
time: AnimationTime(0.0),
- pose: fparkan_animation::Pose::default(),
+ pose: Pose::default(),
}],
frame_map: Vec::new(),
frame_count: 0,
@@ -7902,47 +11801,30 @@ mod tests {
}
}
- fn collision_test_terrain() -> Arc<TerrainWorld> {
- let mut land = fparkan_terrain_format::LandMeshDocument {
- streams: Vec::new(),
- nodes_raw: Vec::new(),
- slots: fparkan_terrain_format::TerrainSlotTable {
- header_raw: Vec::new(),
- slots_raw: Vec::new(),
- },
- positions: vec![
- [-100.0, -100.0, 0.0],
- [100.0, -100.0, 0.0],
- [0.0, 100.0, 0.0],
- ],
- normals: Vec::new(),
- uv0: Vec::new(),
- accelerator: Vec::new(),
- aux14: Vec::new(),
- aux18: Vec::new(),
- faces: vec![fparkan_terrain_format::TerrainFace28 {
- flags: FullSurfaceMask(1),
- material_tag: 0,
- aux_tag: 0,
- vertices: [0, 1, 2],
- neighbors: [None, None, None],
- tail_raw: [0; 8],
- raw: [0; 28],
- }],
- };
- set_test_terrain_lod0_range(&mut land);
- Arc::new(TerrainWorld::from_land_msh(&land).expect("collision test terrain"))
- }
-
fn collision_test_app(
model: Arc<ModelAsset>,
root_transform: LegacyIron3dEulerTransform,
position: [f32; 3],
) -> StaticVulkanApp {
+ let (mut app, _) = clock_test_app(None);
let hierarchy = static_preview_component_pose_buffer(&model, &[], None)
.expect("fixture static preview pose")
.expect("fixture Node38 pose");
- let camera = FreeFlightCamera {
+ app.component_pose_buffers = vec![vec![Some(hierarchy)]];
+ app.component_mount_poses = vec![vec![None]];
+ app.dynamic_component_geometry = vec![vec![None]];
+ app.control_animation_instances = vec![vec![None]];
+ app.building_collision_bindings = vec![building_collision_binding(
+ 0,
+ 0,
+ "collision fixture".to_string(),
+ Arc::clone(&model),
+ root_transform,
+ [1.0; 3],
+ false,
+ )
+ .expect("fixture building collision binding")];
+ app.free_camera = Some(FreeFlightCamera {
position,
yaw: -std::f32::consts::FRAC_PI_2,
pitch: 0.0,
@@ -7951,143 +11833,12 @@ mod tests {
far_plane: 700.0,
move_speed: 60.0,
ground_clearance: 0.0,
- };
- let view_camera = camera.preview_frame(16.0 / 9.0);
- let preview = StaticPreviewScene {
- mesh: VulkanStaticMesh {
- vertices: Vec::new(),
- indices: Vec::new(),
- draw_ranges: Vec::new(),
- },
- native_handedness_by_range: HashMap::new(),
- type16_present_ranges: HashSet::new(),
- materials: Vec::new(),
- material_animations: Vec::new(),
- sun_occlusion_range_indices: Vec::new(),
- shadow_casters: Vec::new(),
- shadow_sphere_baselines: Vec::new(),
- shadow_receivers: Vec::new(),
- shadow_material_index: 0,
- terrain: collision_test_terrain(),
- component_pose_buffers: vec![vec![Some(hierarchy)]],
- component_mount_poses: vec![vec![None]],
- control_animation_instances: Vec::new(),
- dynamic_component_geometry: Vec::new(),
- building_collision_bindings: vec![building_collision_binding(
- 0,
- 0,
- "collision fixture".to_string(),
- Arc::clone(&model),
- root_transform,
- [1.0; 3],
- false,
- )
- .expect("fixture building collision binding")],
- dynamic_component_mounts: Vec::new(),
- shadow_caster_indices_by_object: vec![None],
- camera: view_camera.vulkan_camera(),
- view_camera,
- captured_viewport: None,
- free_camera: Some(camera),
- camera_mode: "test",
- mesh_components: 0,
- terrain_components: 0,
- };
- let mut app = StaticVulkanApp::new(
- preview,
- None,
- None,
- EnvironmentGpuRange {
- range_index: usize::MAX,
- vertex_start: 0,
- vertex_capacity: 0,
- material_index: 0,
- },
- 0,
- 0,
- None,
- 0,
- None,
- "collision-test",
- 1,
- None,
- false,
- StartupTimings::default(),
- );
+ });
app.pressed_keys.insert(KeyCode::KeyW);
app
}
#[test]
- fn mounted_static_building_binding_matches_renderer_and_hides_root_node() {
- let model = collision_fixture_model(true);
- let binding = building_collision_binding(
- 4,
- 5,
- "static fixture model".to_string(),
- Arc::new(model.clone()),
- LegacyIron3dEulerTransform {
- translation: [13.0, -4.0, 9.0],
- orientation_radians: [0.4, -0.3, 0.7],
- },
- [2.0, 3.0, 0.5],
- true,
- )
- .expect("static building collision binding");
- assert_eq!(binding.selected_nodes, vec![(NodeId(1), SlotId(0))]);
-
- let root = VulkanNodePose {
- translation: [1.0, 2.0, 3.0],
- rotation: [0.0, 0.0, 0.29552022, 0.9553365],
- };
- let child = compose_unit_pose(
- root,
- VulkanNodePose {
- translation: [2.0, -1.0, 0.5],
- rotation: [0.0, 0.19866933, 0.0, 0.9800666],
- },
- );
- let hierarchy = NodePoseBuffer {
- poses: [root, child]
- .into_iter()
- .map(|pose| fparkan_animation::Pose {
- translation: pose.translation,
- rotation: pose.rotation,
- })
- .collect(),
- };
- let mount = VulkanNodePose {
- translation: [5.0, 7.0, 11.0],
- rotation: [0.0, 0.0, 0.38268343, 0.9238795],
- };
- let nodes = building_collision_nodes(&binding, Some(&hierarchy), Some(mount))
- .expect("mounted collision matrices");
- assert_eq!(nodes.len(), 1);
- assert_eq!(nodes[0].node, NodeId(1));
-
- let projected = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
- &model,
- binding.root_transform,
- binding.owner_scale,
- &hierarchy,
- mount,
- )
- .expect("mounted renderer projection");
- assert_eq!(projected.vertices.len(), 3);
- for (actual, source) in projected.vertices.iter().zip(&model.positions) {
- let matrix = nodes[0].world_from_node;
- let expected = [
- matrix[0] * source[0] + matrix[1] * source[1] + matrix[2] * source[2] + matrix[3],
- matrix[4] * source[0] + matrix[5] * source[1] + matrix[6] * source[2] + matrix[7],
- matrix[8] * source[0] + matrix[9] * source[1] + matrix[10] * source[2] + matrix[11],
- ];
- for axis in 0..3 {
- assert!((actual.position[axis] - expected[axis]).abs() < 1.0e-4);
- }
- }
- }
-
- #[test]
fn high_speed_free_flight_stops_at_static_building_wall() {
let mut app = collision_test_app(
Arc::new(collision_fixture_model(false)),
@@ -8179,27 +11930,25 @@ mod tests {
.expect("second placed building");
assert!(Arc::ptr_eq(&far.model, &near.model));
- let mut app = collision_test_app(
- Arc::clone(&model),
- LegacyIron3dEulerTransform {
- translation: [0.0, 100.0, 0.0],
- orientation_radians: [0.0; 3],
- },
- [0.0, 8.0, 0.0],
- );
+ let (mut app, _) = clock_test_app(None);
let hierarchy = static_preview_component_pose_buffer(&model, &[], None)
- .expect("fixture static pose")
- .expect("fixture Node38 pose");
+ .expect("shared fixture static pose")
+ .expect("shared fixture Node38 pose");
app.component_pose_buffers = vec![vec![Some(hierarchy.clone())], vec![Some(hierarchy)]];
app.component_mount_poses = vec![vec![None], vec![None]];
app.building_collision_bindings = vec![far, near];
+ app.free_camera.as_mut().unwrap().position = [0.0, 8.0, 0.0];
+ app.free_camera.as_mut().unwrap().yaw = -std::f32::consts::FRAC_PI_2;
+ app.free_camera.as_mut().unwrap().pitch = 0.0;
+ app.free_camera.as_mut().unwrap().move_speed = 60.0;
+ app.pressed_keys.insert(KeyCode::KeyW);
app.update_free_camera(0.1, [1280.0, 720.0])
.expect("both placed buildings are queried independently");
assert_eq!(app.free_camera.unwrap().position, [0.0, 8.0, 0.0]);
}
#[test]
- fn collision_uses_updated_control_pose_and_pending_retry_does_not_move_again() {
+ fn collision_uses_updated_control_pose_and_retries_do_not_move_again() {
let model = Arc::new(collision_fixture_model(false));
let mut app = collision_test_app(
Arc::clone(&model),
@@ -8209,54 +11958,39 @@ mod tests {
},
[0.0, 8.0, 0.0],
);
- let mut control = control_pose_test_control();
- control.model_node_count = 2;
+ let mut control = socket_timeline_control();
control.timeline_rows[0].node_index = 1;
- if let PreparedControlGateProvider::Resolved {
- initial_type1_value,
- ..
- } = &mut control.typed_records[0].gate_provider
- {
- *initial_type1_value = 0.0;
- } else {
- panic!("fixture has a resolved NDP gate");
- }
- app.control_animation_instances = vec![ControlAnimationInstance {
- object_index: 0,
- component_index: 0,
- model: Arc::clone(&model),
+ app.control_animation_instances[0][0] = Some(ControlAnimationInstance {
control,
+ model: (*model).clone(),
+ gate_values: vec![Some(0.0)],
state: ControlInstanceState::default(),
- }];
+ last_sampled_node_times: Vec::new(),
+ reported_unsupported_fallback: false,
+ });
let first = FrameClockSample {
id: SceneSampleId::Replay(0),
time_ms: 0,
delta_seconds: 0.1,
};
- let update = app
- .prepare_scene_update(first, [1280.0, 720.0])
+ app.prepare_scene_update(first, [1280.0, 720.0])
.expect("closed moving wall blocks the first frame");
- assert!(app.lifecycle.prepare(update));
let safe_position = app.free_camera.unwrap().position;
let closed_pose = app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation;
assert_eq!(safe_position, [0.0, 8.0, 0.0]);
- if let PreparedControlGateProvider::Resolved {
- initial_type1_value,
- ..
- } = &mut app.control_animation_instances[0].control.typed_records[0].gate_provider
- {
- *initial_type1_value = 1.0;
- }
- app.refresh_pending_viewport([1024.0, 768.0])
+ app.control_animation_instances[0][0]
+ .as_mut()
+ .unwrap()
+ .gate_values[0] = Some(1.0);
+ app.prepare_scene_update(first, [1024.0, 768.0])
.expect("pending retry only refreshes its viewport");
assert_eq!(app.free_camera.unwrap().position, safe_position);
assert_eq!(
app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation,
closed_pose
);
- assert_eq!(app.lifecycle.record_presented(), Some(0));
app.prepare_scene_update(
FrameClockSample {
@@ -8274,14 +12008,7 @@ mod tests {
#[test]
fn missing_free_camera_keeps_legacy_view_without_querying_buildings() {
- let mut app = collision_test_app(
- Arc::new(collision_fixture_model(false)),
- LegacyIron3dEulerTransform {
- translation: [0.0, 5.0, 0.0],
- orientation_radians: [0.0; 3],
- },
- [0.0, 8.0, 0.0],
- );
+ let (mut app, _) = clock_test_app(None);
app.free_camera = None;
app.building_collision_bindings
.push(BuildingCollisionBinding {
@@ -8306,570 +12033,10 @@ mod tests {
}
#[test]
- fn control_timeline_and_ndp_gate_change_component_pose_at_sampled_time() {
- let model = control_pose_test_model();
- let control = control_pose_test_control();
- let gate_values = initial_control_gate_values(&control);
- let mut raw_state = ControlInstanceState::default();
- let raw_initial = sample_control_node_times(&control, 0, &gate_values, &mut raw_state)
- .expect("initial raw control timeline");
- let raw_later = sample_control_node_times(&control, 225, &gate_values, &mut raw_state)
- .expect("later raw control timeline");
- assert_eq!(raw_initial, vec![Some(0.0)]);
- assert!((raw_later[0].expect("node 0 time") - 0.225).abs() < 1.0e-5);
-
- let mut state = ControlInstanceState::default();
- let initial = static_preview_control_pose_buffer(&model, &control, 0, &mut state)
- .expect("initial control pose")
- .expect("sampled initial hierarchy");
- let later = static_preview_control_pose_buffer(&model, &control, 225, &mut state)
- .expect("later control pose")
- .expect("sampled later hierarchy");
-
- assert_eq!(initial.poses[0].translation, [0.0; 3]);
- assert!(
- (later.poses[0].translation[1] - 0.45).abs() < 1.0e-4,
- "later sampled pose: {:?}",
- later.poses[0]
- );
-
- let mut closed_gate = control.clone();
- let ndp_source = match &control.typed_records[0].gate_provider {
- PreparedControlGateProvider::Resolved { ndp_source, .. } => ndp_source.clone(),
- _ => panic!("fixture has a resolved NDP source"),
- };
- closed_gate.typed_records[0].gate_provider = PreparedControlGateProvider::Resolved {
- key: 0,
- ndp_source,
- row_count: 1,
- initial_type1_value: 0.0,
- };
- let mut closed_state = ControlInstanceState::default();
- let closed_initial =
- static_preview_control_pose_buffer(&model, &closed_gate, 0, &mut closed_state)
- .expect("closed-gate initial pose")
- .expect("closed-gate initial hierarchy");
- let closed_later =
- static_preview_control_pose_buffer(&model, &closed_gate, 225, &mut closed_state)
- .expect("closed-gate control pose")
- .expect("closed-gate hierarchy");
- assert_eq!(initial.poses[0], closed_initial.poses[0]);
- assert_eq!(closed_initial.poses[0], closed_later.poses[0]);
- assert_ne!(later.poses[0], closed_later.poses[0]);
-
- let mut mismatched_control = control.clone();
- mismatched_control.model_node_count += 1;
- let mismatch = static_preview_control_pose_buffer(
- &model,
- &mismatched_control,
- 225,
- &mut ControlInstanceState::default(),
- )
- .expect_err("control/mesh node-count mismatch is rejected");
- assert!(mismatch.contains("does not match MSH node count"));
- }
-
- #[test]
- fn exto_parent_control_updates_visible_child_mount_without_draw_geometry() {
- let mut model = control_pose_test_model();
- let mut socket_node = vec![u8::MAX; model.node_stride];
- socket_node[..2].copy_from_slice(&[0, 0]);
- socket_node[2..4].copy_from_slice(&0_u16.to_le_bytes());
- socket_node[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
- socket_node[6..8].copy_from_slice(&1_u16.to_le_bytes());
- model.nodes_raw.extend(socket_node);
- model.node_count = 2;
- let mut control = control_pose_test_control();
- control.model_node_count = 2;
- let unit_record = |immediate_child_count| fparkan_prototype::UnitComponentRecord {
- archive_raw: [0; 32],
- resource_raw: [0; 32],
- kind: 0,
- parent_or_link: 1,
- description_raw: [0; 32],
- tail0: 0,
- immediate_child_count,
- };
- let unit_tree = unit_component_tree(&[unit_record(1), unit_record(1), unit_record(0)])
- .expect("preorder Unit DAT tree");
- let controls = vec![None, Some(control.clone()), None];
- assert!(unit_component_is_visible(1, Some(PROTOTYPE_TYPE_EXTO)));
- assert!(unit_component_is_visible(2, Some(PROTOTYPE_TYPE_EXTO)));
- assert!(component_chain_has_tag21(Some(&unit_tree), &controls, 2));
- assert_eq!(unit_tree[1].parent_index, Some(0));
- assert_eq!(unit_tree[2].parent_index, Some(1));
- assert!(
- unit_tree[2]
- .parent_index
- .is_some_and(|parent_index| parent_index < 2),
- "Unit DAT preorder places a parent before its child"
- );
- let mut state = ControlInstanceState::default();
- let initial_hierarchy = static_preview_control_pose_buffer(&model, &control, 0, &mut state)
- .expect("initial EXTO parent pose")
- .expect("initial parent hierarchy");
- let later_hierarchy = static_preview_control_pose_buffer(&model, &control, 225, &mut state)
- .expect("animated EXTO parent pose")
- .expect("animated parent hierarchy");
- let mut pose_buffers = vec![vec![None, Some(initial_hierarchy), None]];
- let mounts = [DynamicComponentMount {
- object_index: 0,
- component_index: 2,
- parent_index: 1,
- socket: 1,
- }];
- let initial = node38_pose_from_hierarchy(
- pose_buffers[0][1].as_ref().expect("EXTO parent hierarchy"),
- 1,
- )
- .expect("parent socket");
- let mut mount_poses = vec![vec![None, None, Some(initial)]];
- pose_buffers[0][1] = Some(later_hierarchy);
- let parent_geometry = DynamicComponentGeometry {
- object_index: 0,
- component_index: 1,
- model: Arc::new(model.clone()),
- transform: LegacyIron3dEulerTransform {
- translation: [0.0; 3],
- orientation_radians: [0.0; 3],
- },
- scale: [1.0; 3],
- vertex_start: 0,
- vertex_count: 0,
- shadow_triangle_range: None,
- };
- let empty_parent_mesh = project_dynamic_component_geometry(
- &parent_geometry,
- pose_buffers[0][1].as_ref().expect("EXTO parent hierarchy"),
- None,
- )
- .expect("zero-geometry EXTO parent");
- assert!(empty_parent_mesh.vertices.is_empty());
- assert!(empty_parent_mesh.indices.is_empty());
-
- assert!(
- refresh_dynamic_component_mounts(&mounts, &pose_buffers, &mut mount_poses)
- .expect("refresh child mount")
- );
- assert_eq!(
- mount_poses[0][2].expect("child mount").translation,
- [0.0, 2.45, 0.0]
- );
- // The EXTO parent has a sampled Node38 hierarchy but no projected draw
- // vertices. Its hierarchy still drives the visible child's mount.
- }
-
- #[test]
- fn live_tick_and_pending_resize_keep_the_scene_sample_while_refreshing_projection() {
- let model = Arc::new(control_pose_drawable_test_model());
- let control = control_pose_test_control();
- let dynamic = DynamicComponentGeometry {
- object_index: 0,
- component_index: 0,
- model: Arc::clone(&model),
- transform: LegacyIron3dEulerTransform {
- translation: [0.0; 3],
- orientation_radians: [0.0; 3],
- },
- scale: [1.0; 3],
- vertex_start: 0,
- vertex_count: 3,
- shadow_triangle_range: Some(0..1),
- };
- let mut state = ControlInstanceState::default();
- let initial_pose = static_preview_control_pose_buffer(&model, &control, 0, &mut state)
- .expect("initial hierarchy")
- .expect("initial Node38 pose");
- let initial_mesh = project_dynamic_component_geometry(&dynamic, &initial_pose, None)
- .expect("initial projected component");
- let initial_triangles = static_mesh_shadow_triangles(&initial_mesh);
- let baseline = ShadowSphere {
- center: [0.0; 3],
- radius: 1.0,
- };
- let mut land = fparkan_terrain_format::LandMeshDocument {
- streams: Vec::new(),
- nodes_raw: Vec::new(),
- slots: fparkan_terrain_format::TerrainSlotTable {
- header_raw: Vec::new(),
- slots_raw: Vec::new(),
- },
- positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
- normals: Vec::new(),
- uv0: Vec::new(),
- accelerator: Vec::new(),
- aux14: Vec::new(),
- aux18: Vec::new(),
- faces: vec![fparkan_terrain_format::TerrainFace28 {
- flags: FullSurfaceMask(1),
- material_tag: 0,
- aux_tag: 0,
- vertices: [0, 1, 2],
- neighbors: [None, None, None],
- tail_raw: [0; 8],
- raw: [0; 28],
- }],
- };
- set_test_terrain_lod0_range(&mut land);
- let terrain = Arc::new(TerrainWorld::from_land_msh(&land).expect("test terrain"));
- let free_camera = FreeFlightCamera {
- position: [0.25, 0.25, 0.0],
- yaw: 0.0,
- pitch: 0.0,
- horizontal_fov: 1.0,
- near_plane: 0.1,
- far_plane: 100.0,
- move_speed: 1.0,
- ground_clearance: 0.0,
- };
- let view_camera = free_camera.preview_frame(16.0 / 9.0);
- let preview = StaticPreviewScene {
- mesh: initial_mesh.clone(),
- native_handedness_by_range: HashMap::new(),
- type16_present_ranges: HashSet::new(),
- materials: Vec::new(),
- material_animations: Vec::new(),
- sun_occlusion_range_indices: Vec::new(),
- shadow_casters: vec![ShadowCaster {
- id: 0,
- world_axes: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
- sphere: baseline,
- native_kind: 4,
- triangles: initial_triangles.clone(),
- }],
- shadow_sphere_baselines: vec![baseline],
- shadow_receivers: Vec::new(),
- shadow_material_index: 0,
- terrain,
- component_pose_buffers: vec![vec![Some(initial_pose)]],
- component_mount_poses: vec![vec![None]],
- control_animation_instances: vec![ControlAnimationInstance {
- object_index: 0,
- component_index: 0,
- model: Arc::clone(&model),
- control,
- state,
- }],
- dynamic_component_geometry: vec![dynamic],
- building_collision_bindings: Vec::new(),
- dynamic_component_mounts: Vec::new(),
- shadow_caster_indices_by_object: vec![Some(0)],
- camera: view_camera.vulkan_camera(),
- view_camera,
- captured_viewport: None,
- free_camera: None,
- camera_mode: "test",
- mesh_components: 1,
- terrain_components: 0,
- };
- let mut app = StaticVulkanApp::new(
- preview,
- None,
- None,
- EnvironmentGpuRange {
- range_index: usize::MAX,
- vertex_start: initial_mesh.vertices.len(),
- vertex_capacity: 0,
- material_index: 0,
- },
- initial_mesh.vertices.len(),
- initial_mesh.indices.len(),
- None,
- 0,
- None,
- "control-test",
- 1,
- None,
- false,
- StartupTimings::default(),
- );
- app.free_camera = Some(free_camera);
- app.pressed_keys.insert(KeyCode::KeyW);
- app.materials = packet_test_materials();
- app.material_animations = vec![random_material_binding(0)];
- let empty_range = app.shadow_range;
- app.environment_gpu = Some(EnvironmentGpuScene {
- material_animations: vec![random_material_binding(1)],
- sky_layers: Vec::new(),
- sprites: Vec::new(),
- rain: empty_range,
- snow: empty_range,
- lightning: empty_range,
- lightning_material_index: 0,
- });
- let start = app.last_tick;
- let sample = app.sample_live_frame_clock_at(start + Duration::from_millis(225));
- assert_eq!(sample.time_ms, 225);
- assert!((sample.delta_seconds - 0.225).abs() < f32::EPSILON);
- let mut update = app
- .prepare_scene_update(sample, [1280.0, 720.0])
- .expect("apply one full live tick to the scene");
- assert!(update.environment.is_none());
- assert!(update.environment_material_update.is_some());
- let world_material_packet = update.material_update.clone();
- let sky_material_packet = update.environment_material_update.clone();
- assert_eq!(app.material_animations[0].base.random_state, 2_531_011);
- assert_eq!(
- app.environment_gpu.as_ref().unwrap().material_animations[0]
- .base
- .random_state,
- 2_531_011
- );
- let expected_resized_camera = app
- .free_camera
- .expect("free-flight input controller")
- .preview_frame(4.0 / 3.0);
- let camera_after_tick = app.free_camera.expect("free-flight camera").position;
- assert!((camera_after_tick[0] - 0.35).abs() < 1.0e-5);
- assert!((camera_after_tick[1] - 0.25).abs() < 1.0e-5);
- assert_eq!(app.material_time_ms, sample.time_ms);
- let weather = WeatherInput {
- rain: WeatherState::new(true, 0.03, [0.7, 0.8, 1.0, 0.6]),
- snow: WeatherState::new(true, 0.03, [0.9, 0.95, 1.0, 0.8]),
- ..WeatherInput::clear()
- };
- let mut weather_system = EnvironmentSystem::new(0xC1_225);
- let first_environment_frame = weather_system.update(
- 0.225,
- weather,
- environment_camera_for_preview(&update.camera, update.aspect, update.viewport),
- );
- let mut precipitation_counts = [0usize; 2];
- for primitive in &first_environment_frame.primitives {
- if let EnvironmentPrimitive::Particle { kind, .. } = primitive {
- let slot = match kind {
- PrecipitationKind::Rain => 0,
- PrecipitationKind::Snow => 1,
- };
- precipitation_counts[slot] += 1;
- }
- }
- assert!(precipitation_counts.iter().all(|count| *count > 0));
- update.environment = Some(DynamicEnvironmentFrame {
- atmosphere: TypedAtmosphere {
- marker: 0,
- version: 0,
- tracks: Vec::new(),
- trailer: Default::default(),
- }
- .sample(0.225),
- sky_mesh: SkySystem::new(Default::default(), SkyMaterials::default())
- .expect("test sky topology")
- .mesh()
- .clone(),
- environment_frame: first_environment_frame.clone(),
- absolute_time_seconds: 0.225,
- });
- assert!(app.lifecycle.prepare(update.clone()));
- let control_state_after_tick = app.control_animation_instances[0].state.clone();
- assert_eq!(
- app.sample_live_frame_clock_at(start + Duration::from_millis(500)),
- sample
- );
- assert_eq!(app.simulation_elapsed, Duration::from_millis(225));
- assert_eq!(app.lifecycle.pending.as_ref(), Some(&update));
-
- assert_eq!(app.mesh.indices, initial_mesh.indices);
- assert_eq!(app.mesh.vertices.len(), initial_mesh.vertices.len());
- assert_ne!(
- initial_mesh.vertices[0].position,
- app.mesh.vertices[0].position
- );
- assert!((app.mesh.vertices[0].position[1] - 0.45).abs() < 1.0e-4);
- let b_vertices = app.mesh.vertices.clone();
- let b_triangles = app.shadow_casters[0].triangles.clone();
- let b_sphere = app.shadow_casters[0].sphere;
- assert_ne!(b_triangles, initial_triangles);
- assert!(b_sphere.radius > baseline.radius);
-
- app.free_camera.as_mut().unwrap().look_delta(64.0, -24.0);
- let controller_after_input = app.free_camera.unwrap();
- app.refresh_pending_viewport([800.0, 600.0])
- .expect("refresh only viewport-dependent state after recreation");
- let refreshed = app
- .lifecycle
- .pending
- .as_ref()
- .expect("pending sample")
- .clone();
- assert_eq!(refreshed.sample, sample);
- assert_eq!(refreshed.material_update, world_material_packet);
- assert_eq!(refreshed.environment_material_update, sky_material_packet);
- assert_eq!(app.material_animations[0].base.random_state, 2_531_011);
- assert_eq!(
- app.environment_gpu.as_ref().unwrap().material_animations[0]
- .base
- .random_state,
- 2_531_011
- );
- app.refresh_pending_viewport([1024.0, 768.0])
- .expect("refresh the same pending sample a second time");
- let repeated = app.lifecycle.pending.as_ref().expect("pending sample");
- assert_eq!(repeated.sample, sample);
- assert_eq!(repeated.material_update, world_material_packet);
- assert_eq!(repeated.environment_material_update, sky_material_packet);
- assert_eq!(repeated.viewport, [1024.0, 768.0]);
- assert_eq!(repeated.aspect, 4.0 / 3.0);
- assert_eq!(app.material_animations[0].base.random_state, 2_531_011);
- assert_eq!(
- app.environment_gpu.as_ref().unwrap().material_animations[0]
- .base
- .random_state,
- 2_531_011
- );
- assert_eq!(refreshed.viewport, [800.0, 600.0]);
- assert_eq!(refreshed.aspect, 4.0 / 3.0);
- assert_eq!(refreshed.camera.position, update.camera.position);
- assert_eq!(refreshed.camera.forward, update.camera.forward);
- assert_eq!(refreshed.camera.right, update.camera.right);
- assert_eq!(refreshed.camera.up, update.camera.up);
- assert_eq!(refreshed.camera.position, expected_resized_camera.position);
- assert_eq!(refreshed.camera.forward, expected_resized_camera.forward);
- assert_eq!(refreshed.camera.right, expected_resized_camera.right);
- assert_eq!(refreshed.camera.up, expected_resized_camera.up);
- assert!(
- (refreshed.camera.vertical_fov - expected_resized_camera.vertical_fov).abs() < 1.0e-6
- );
- assert!(refreshed
- .camera
- .clip_from_world
- .iter()
- .zip(expected_resized_camera.clip_from_world)
- .all(|(actual, expected)| (actual - expected).abs() < 1.0e-5));
- assert_ne!(
- refreshed.camera.clip_from_world,
- update.camera.clip_from_world
- );
- assert_eq!(app.free_camera, Some(controller_after_input));
- let refreshed_environment_frame =
- &refreshed.environment.as_ref().unwrap().environment_frame;
- assert_eq!(
- refreshed_environment_frame.point_lights,
- first_environment_frame.point_lights
- );
- assert_eq!(
- refreshed_environment_frame.sounds,
- first_environment_frame.sounds
- );
- let mut reprojected_counts = [0usize; 2];
- for (sampled, refreshed) in first_environment_frame
- .primitives
- .iter()
- .zip(&refreshed_environment_frame.primitives)
- {
- let (
- EnvironmentPrimitive::Particle {
- kind: sampled_kind,
- position: sampled_position,
- velocity: sampled_velocity,
- size: sampled_size,
- screen: sampled_screen,
- ..
- },
- EnvironmentPrimitive::Particle {
- kind,
- position,
- velocity,
- size,
- screen,
- ..
- },
- ) = (sampled, refreshed)
- else {
- panic!("rain and snow fixture contains only precipitation particles");
- };
- assert_eq!(kind, sampled_kind);
- assert_eq!(position, sampled_position);
- assert_eq!(velocity, sampled_velocity);
- assert_ne!(screen, sampled_screen);
- assert_ne!(size, sampled_size);
- let slot = match kind {
- PrecipitationKind::Rain => 0,
- PrecipitationKind::Snow => 1,
- };
- reprojected_counts[slot] += 1;
- }
- assert!(reprojected_counts.iter().all(|count| *count > 0));
- assert_eq!(
- app.apply_pending_scene_update().unwrap_err(),
- "native Vulkan renderer was not initialized"
- );
- let restored = app
- .lifecycle
- .pending
- .as_ref()
- .expect("restored pending sample");
- assert_eq!(restored.sample, sample);
- assert_eq!(restored.material_update, world_material_packet);
- assert_eq!(restored.environment_material_update, sky_material_packet);
- assert_eq!(app.material_animations[0].base.random_state, 2_531_011);
- assert_eq!(
- app.environment_gpu.as_ref().unwrap().material_animations[0]
- .base
- .random_state,
- 2_531_011
- );
- assert_eq!(app.mesh.vertices, b_vertices);
- assert_eq!(app.shadow_casters[0].triangles, b_triangles);
- assert_eq!(
- app.control_animation_instances[0].state,
- control_state_after_tick
- );
- assert_eq!(app.material_time_ms, sample.time_ms);
- assert_eq!(app.simulation_elapsed, Duration::from_millis(225));
- assert_eq!(app.lifecycle.frames_presented, 0);
- assert_eq!(app.lifecycle.record_presented(), Some(0));
-
- app.update_controlled_component_geometry(sample)
- .expect("repeat the same control timestamp");
- assert_eq!(app.mesh.vertices, b_vertices);
- assert_eq!(app.shadow_casters[0].triangles, b_triangles);
- assert_eq!(app.shadow_casters[0].sphere, b_sphere);
-
- let first_sample = FrameClockSample {
- id: SceneSampleId::Live(0),
- time_ms: 0,
- delta_seconds: 0.0,
- };
- app.update_material_clock(first_sample);
- app.update_controlled_component_geometry(first_sample)
- .expect("return to first control pose");
- assert_eq!(app.mesh.vertices, initial_mesh.vertices);
- assert_eq!(app.shadow_casters[0].triangles, initial_triangles);
- assert_eq!(app.shadow_casters[0].sphere, baseline);
-
- let next_sample = app.sample_live_frame_clock_at(start + Duration::from_millis(241));
- assert_eq!(next_sample.id, SceneSampleId::Live(1));
- assert_eq!(next_sample.time_ms, 241);
- let next_update = app
- .prepare_scene_update(next_sample, [800.0, 600.0])
- .expect("the next sampled frame adopts mouse input received during retry");
- assert_ne!(next_update.camera.forward, update.camera.forward);
- assert_eq!(next_update.camera.forward, controller_after_input.forward());
- }
-
- #[test]
#[ignore = "requires licensed corpus"]
fn licensed_plant_wall_and_door_use_the_app_camera_bridge() {
use std::path::PathBuf;
- fn set_segment(app: &mut StaticVulkanApp, segment: [[f32; 3]; 2]) {
- let delta = sub3(segment[1], segment[0]);
- let horizontal_distance = (delta[0] * delta[0] + delta[1] * delta[1]).sqrt();
- app.free_camera = Some(FreeFlightCamera {
- position: segment[0],
- yaw: delta[1].atan2(delta[0]),
- pitch: delta[2].atan2(horizontal_distance),
- horizontal_fov: 1.3,
- near_plane: 0.5,
- far_plane: 700.0,
- move_speed: length3(delta).expect("nonzero native test segment") / 0.1,
- ground_clearance: 0.0,
- });
- app.pressed_keys.clear();
- app.pressed_keys.insert(KeyCode::KeyW);
- }
-
const BODY_WALL: [[f32; 3]; 2] = [[-10.5, 22.16, 2.5], [-10.5, 19.16, 2.5]];
const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]];
let root = PathBuf::from(
@@ -8927,11 +12094,12 @@ mod tests {
0,
None,
2,
- None,
mission,
loaded.object_count,
None,
+ None,
false,
+ None,
StartupTimings::default(),
);
let plant = app
@@ -8972,7 +12140,6 @@ mod tests {
assert_eq!(closed_door_hit.node, NodeId(3));
assert_eq!(closed_door_hit.batch_index, 100);
assert_eq!(closed_door_hit.triangle_descriptor_index, 855);
-
let terrain_bounds = app
.terrain
.surface_bounds()
@@ -9024,48 +12191,44 @@ mod tests {
assert_eq!(constrained.position, point);
}
- set_segment(&mut app, world_wall);
+ set_collision_test_segment(&mut app, world_wall);
let zero_delta = FrameClockSample {
id: SceneSampleId::Replay(0),
time_ms: 0,
delta_seconds: 0.0,
};
- let update = app
- .prepare_scene_update(zero_delta, [1280.0, 720.0])
+ app.prepare_scene_update(zero_delta, [1280.0, 720.0])
.expect("zero-delta resume initialization");
- assert!(app.lifecycle.prepare(update));
assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
- assert_eq!(app.lifecycle.record_presented(), Some(0));
+ assert_eq!(app.record_presented_scene_frame(), 0);
let wall_frame = FrameClockSample {
id: SceneSampleId::Replay(1),
time_ms: 0,
delta_seconds: 0.1,
};
- let update = app
- .prepare_scene_update(wall_frame, [1280.0, 720.0])
+ app.prepare_scene_update(wall_frame, [1280.0, 720.0])
.expect("high-speed wall movement through the app update path");
- assert!(app.lifecycle.prepare(update));
assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
app.pressed_keys.clear();
app.pressed_keys.insert(KeyCode::KeyD);
- app.refresh_pending_viewport([1024.0, 768.0])
+ app.prepare_scene_update(wall_frame, [1024.0, 768.0])
.expect("retry of the pending wall frame");
assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
- assert_eq!(app.lifecycle.record_presented(), Some(1));
+ assert_eq!(app.record_presented_scene_frame(), 1);
- set_segment(&mut app, world_door);
- let closed_frame = FrameClockSample {
- id: SceneSampleId::Replay(2),
- time_ms: 0,
- delta_seconds: 0.1,
- };
- let update = app
- .prepare_scene_update(closed_frame, [1024.0, 768.0])
- .expect("the closed Node3 door blocks through the app update path");
- assert!(app.lifecycle.prepare(update));
+ set_collision_test_segment(&mut app, world_door);
+ app.prepare_scene_update(
+ FrameClockSample {
+ id: SceneSampleId::Replay(2),
+ time_ms: 0,
+ delta_seconds: 0.1,
+ },
+ [1024.0, 768.0],
+ )
+ .expect("the closed Node3 door blocks through the app update path");
assert_eq!(app.free_camera.unwrap().position, world_door[0]);
- assert_eq!(app.lifecycle.record_presented(), Some(2));
+ assert_eq!(app.record_presented_scene_frame(), 2);
let control = assets
.object_component_controls
@@ -9096,16 +12259,16 @@ mod tests {
assert_eq!(open_node_times[3], Some(AnimationTime(0.0)));
// This samples the door's authored frame B directly; it does not simulate a mission gate.
open_node_times[3] = Some(AnimationTime(1.0));
- app.component_pose_buffers[4][0] = Some(
- node38_sampled_hierarchy_at_times(&plant.model, &open_node_times)
- .expect("sample the actual PLANT model at its authored Node3 frame B"),
- );
- refresh_dynamic_component_mounts(
- &app.dynamic_component_mounts,
+ let open_hierarchy = node38_sampled_hierarchy_at_times(&plant.model, &open_node_times)
+ .expect("sample the actual PLANT model at its authored Node3 frame B");
+ app.component_pose_buffers[4][0] = Some(open_hierarchy);
+ update_dynamic_component_geometry(
+ &app.dynamic_component_geometry,
&app.component_pose_buffers,
&mut app.component_mount_poses,
+ &mut app.mesh,
)
- .expect("refresh mounts from the authored open-door hierarchy");
+ .expect("update the scene hierarchy for the authored door pose");
let open_hierarchy = app.component_pose_buffers[4][0]
.as_ref()
.expect("sampled PLANT root pose");
@@ -9122,7 +12285,7 @@ mod tests {
"the authored Node3 time-1 pose leaves the doorway ray clear"
);
- set_segment(&mut app, world_door);
+ set_collision_test_segment(&mut app, world_door);
app.update_free_camera(0.1, [1024.0, 768.0])
.expect("open doorway movement through the app camera update");
let position = app.free_camera.unwrap().position;
@@ -9131,48 +12294,682 @@ mod tests {
}
}
- fn set_test_terrain_lod0_range(terrain: &mut fparkan_terrain_format::LandMeshDocument) {
- const NODE_STRIDE: usize = 38;
- const SLOT_STRIDE: usize = 68;
- let mut node = vec![u8::MAX; NODE_STRIDE];
- node[8..10].copy_from_slice(&0_u16.to_le_bytes());
- node[10..12].copy_from_slice(&u16::MAX.to_le_bytes());
- let mut slot = [0_u8; SLOT_STRIDE];
- slot[..2].copy_from_slice(&0_u16.to_le_bytes());
- let face_count = u16::try_from(terrain.faces.len()).expect("test face count fits");
- slot[2..4].copy_from_slice(&face_count.to_le_bytes());
- terrain.nodes_raw = node;
- terrain.slots.slots_raw = vec![slot];
+ fn set_collision_test_segment(app: &mut StaticVulkanApp, segment: [[f32; 3]; 2]) {
+ let delta = sub3(segment[1], segment[0]);
+ let horizontal_distance = (delta[0] * delta[0] + delta[1] * delta[1]).sqrt();
+ app.free_camera = Some(FreeFlightCamera {
+ position: segment[0],
+ yaw: delta[1].atan2(delta[0]),
+ pitch: delta[2].atan2(horizontal_distance),
+ horizontal_fov: 1.3,
+ near_plane: 0.5,
+ far_plane: 700.0,
+ move_speed: length3(delta).expect("nonzero native test segment") / 0.1,
+ ground_clearance: 0.0,
+ });
+ app.pressed_keys.clear();
+ app.pressed_keys.insert(KeyCode::KeyW);
}
- fn strings(values: &[&str]) -> Vec<String> {
- values.iter().map(|value| (*value).to_string()).collect()
+ fn socket_timeline_control() -> PreparedComponentControl {
+ let mut fixed_words = [0; 44];
+ fixed_words[2] = 0;
+ fixed_words[6] = u32::MAX;
+ fixed_words[9] = 1.0_f32.to_bits();
+ fixed_words[10] = 1.0_f32.to_bits();
+ PreparedComponentControl {
+ source: ResourceKey {
+ archive: normalize_relative(b"test.rlb", PathPolicy::HostCompatible)
+ .expect("archive path"),
+ name: resource_name(b"animated.ctl"),
+ type_id: None,
+ },
+ native_profile_words: [0; 27],
+ model_node_count: 2,
+ bindings: vec![],
+ timeline_rows: vec![PreparedControlTimelineRow {
+ row_index: 0,
+ node_index: 0,
+ frame_a: 0.0,
+ frame_b: 1.0,
+ initial_blend: 0.0,
+ raw_parameters: [u32::MAX, u32::MAX, 1.0_f32.to_bits(), 0],
+ flags: 0,
+ }],
+ typed_records: vec![PreparedControlTypedRecord {
+ tag: 21,
+ fixed_words,
+ values: vec![0],
+ string_bytes: vec![],
+ gate_provider: PreparedControlGateProvider::Resolved {
+ key: 0,
+ ndp_source: ResourceKey {
+ archive: normalize_relative(b"test.rlb", PathPolicy::HostCompatible)
+ .expect("NDP archive path"),
+ name: resource_name(b"animated.ndp"),
+ type_id: None,
+ },
+ row_count: 1,
+ initial_type1_value: 1.0,
+ },
+ }],
+ node_data: None,
+ effect_section: PreparedControlEffectSection::Absent,
+ initial_effect_group: None,
+ point_list: None,
+ }
}
- fn scene_frame_update(sample: FrameClockSample, viewport: [f32; 2]) -> SceneFrameUpdate {
- let aspect = viewport[0] / viewport[1];
- let camera = FreeFlightCamera {
- position: [0.0; 3],
- yaw: 0.0,
- pitch: 0.0,
- horizontal_fov: 1.0,
- near_plane: 0.1,
- far_plane: 100.0,
- move_speed: 1.0,
- ground_clearance: 0.0,
+ fn control_point(position: [f32; 3], direction: [f32; 3]) -> PreparedControlPoint {
+ PreparedControlPoint {
+ raw_flags: 0,
+ raw_signed_field_04: 0,
+ raw_signed_field_08: 0,
+ position,
+ direction,
+ name: [0; 32],
}
- .preview_frame(aspect);
- SceneFrameUpdate {
- sample,
- camera,
- viewport,
- aspect,
- material_update: MaterialFrameUpdate::default(),
- environment_material_update: None,
- environment: None,
+ }
+
+ fn assert_matrix_columns(matrix: [[f32; 4]; 4], expected: [[f32; 3]; 3]) {
+ for (column, expected) in expected.into_iter().enumerate() {
+ for row in 0..3 {
+ assert!(
+ (matrix[row][column] - expected[row]).abs() < 1.0e-5,
+ "column {column} row {row}: {} != {}",
+ matrix[row][column],
+ expected[row]
+ );
+ }
+ }
+ }
+
+ fn write_effect_u32(body: &mut [u8], offset: usize, value: u32) {
+ body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn write_effect_f32(body: &mut [u8], offset: usize, value: f32) {
+ body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn write_effect_vec3(body: &mut [u8], offset: usize, value: [f32; 3]) {
+ for (axis, component) in value.into_iter().enumerate() {
+ write_effect_f32(body, offset + axis * 4, component);
+ }
+ }
+
+ #[test]
+ fn tag21_clock_moves_msh_vertices_normals_child_socket_and_fx_anchor() {
+ let parent_model = animated_socket_model();
+ let child_model = static_child_model();
+ let identity_transform = LegacyIron3dEulerTransform {
+ translation: [0.0; 3],
+ orientation_radians: [0.0; 3],
+ };
+ let initial_parent_pose =
+ node38_sampled_hierarchy_at_times(&parent_model, &[Some(AnimationTime(0.0)), None])
+ .expect("initial parent pose");
+ let initial_child_pose =
+ node38_fallback_hierarchy(&child_model).expect("static child pose");
+ let initial_mount =
+ node38_pose_from_hierarchy(&initial_parent_pose, 1).expect("parent socket pose");
+ let parent_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
+ &parent_model,
+ identity_transform,
+ [1.0; 3],
+ &initial_parent_pose,
+ )
+ .expect("initial parent mesh");
+ let child_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
+ &child_model,
+ identity_transform,
+ [1.0; 3],
+ &initial_child_pose,
+ initial_mount,
+ )
+ .expect("initial mounted child mesh");
+ let parent_vertex_count = parent_mesh.vertices.len();
+ assert_eq!(parent_vertex_count, 3);
+ assert_eq!(child_mesh.vertices.len(), 3);
+ let mut vertices = parent_mesh
+ .vertices
+ .into_iter()
+ .chain(child_mesh.vertices)
+ .collect::<Vec<_>>();
+ let environment_vertex_start =
+ u32::try_from(vertices.len()).expect("environment vertex base fits u32");
+ let mut environment_vertices = shadow_placeholder_vertices();
+ for vertex in &mut environment_vertices {
+ vertex.position[0] += 1000.0;
+ }
+ vertices.extend(environment_vertices);
+ let mut indices = vec![0, 1, 2, 3, 4, 5];
+ indices.extend(
+ quad_indices(1)
+ .expect("environment quad indices")
+ .into_iter()
+ .map(|index| index + environment_vertex_start),
+ );
+ let draw_range = |first_index, index_count| VulkanStaticDrawRange {
+ first_index,
+ index_count,
+ material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: world_pipeline_state(
+ LegacyBlendMode::Opaque,
+ LegacyDepthMode::TestReadOnly,
+ ),
+ terrain_base_only: false,
+ alpha_test_reference: 0,
+ };
+ let mut mesh = VulkanStaticMesh {
+ vertices,
+ indices,
+ draw_ranges: vec![draw_range(0, 3), draw_range(3, 3), draw_range(6, 6)],
+ };
+ let control = socket_timeline_control();
+ let gate_values = initial_control_gate_values(&control);
+ let mut shadow_casters = vec![test_shadow_caster(0, [0.0; 3])];
+ shadow_casters[0].triangles = static_mesh_shadow_triangles_in_index_range(&mesh, 0, 6)
+ .expect("initial caster triangle geometry");
+ let dynamic_shadow_casters = [DynamicShadowCaster {
+ caster_index: 0,
+ object_index: 0,
+ root_transform: identity_transform,
+ owner_scale: [1.0; 3],
+ initial_sphere: shadow_casters[0].sphere,
+ component_draw_range_indices: vec![0, 1],
+ component_spheres: vec![
+ DynamicShadowSphereSource {
+ component_index: 0,
+ center: [0.0; 3],
+ radius: 1.0,
+ mounted: false,
+ },
+ DynamicShadowSphereSource {
+ component_index: 1,
+ center: [0.0; 3],
+ radius: 1.0,
+ mounted: true,
+ },
+ ],
+ }];
+ let mut instances = vec![vec![
+ Some(ControlAnimationInstance {
+ control,
+ model: parent_model.clone(),
+ gate_values,
+ state: ControlInstanceState::default(),
+ last_sampled_node_times: Vec::new(),
+ reported_unsupported_fallback: false,
+ }),
+ None,
+ ]];
+ let mut poses = vec![vec![Some(initial_parent_pose), Some(initial_child_pose)]];
+ let mut mounts = vec![vec![None, Some(initial_mount)]];
+ let geometry = vec![vec![
+ Some(DynamicComponentGeometry {
+ model: parent_model,
+ root_transform: identity_transform,
+ owner_scale: [1.0; 3],
+ parent_mount: None,
+ vertex_start: 0,
+ vertex_count: parent_vertex_count,
+ }),
+ Some(DynamicComponentGeometry {
+ model: child_model,
+ root_transform: identity_transform,
+ owner_scale: [1.0; 3],
+ parent_mount: Some((0, 1)),
+ vertex_start: parent_vertex_count,
+ vertex_count: 3,
+ }),
+ ]];
+
+ // The fixture's resolved NDP row supplies the native initial value 1.0.
+ update_control_animation_pose_buffers(&mut instances, &mut poses, 0)
+ .expect("initial active timeline sample");
+ let first_root_pose = poses[0][0].as_ref().expect("first root pose").poses[0];
+ update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
+ .expect("initial geometry update");
+ let range_remap = reorder_draw_ranges(&mut mesh, &[2, 0, 1]).expect("environment reorder");
+ let mut dynamic_shadow_casters = dynamic_shadow_casters;
+ remap_dynamic_shadow_caster_draw_ranges(&mut dynamic_shadow_casters, &range_remap)
+ .expect("dynamic shadow draw-range remap");
+ refresh_dynamic_shadow_casters(
+ &mut shadow_casters,
+ &dynamic_shadow_casters,
+ &mesh,
+ &poses,
+ &mounts,
+ )
+ .expect("initial caster geometry refresh");
+ let encloses_triangles = |sphere: ShadowSphere, triangles: &[ShadowTriangle]| {
+ triangles
+ .iter()
+ .flat_map(|triangle| triangle.positions)
+ .all(|point| {
+ let offset = sub3(point, sphere.center);
+ let distance = offset[0].hypot(offset[1]).hypot(offset[2]);
+ distance.is_finite() && distance <= sphere.radius + 1.0e-4
+ })
+ };
+ let first_shadow_sphere = shadow_casters[0].sphere;
+ assert!(encloses_triangles(
+ first_shadow_sphere,
+ &shadow_casters[0].triangles
+ ));
+ assert_eq!(
+ dynamic_shadow_casters[0].component_draw_range_indices,
+ [1, 2]
+ );
+ assert_eq!(shadow_casters[0].triangles.len(), 2);
+ assert_eq!(
+ shadow_casters[0].triangles[0].positions[0],
+ mesh.vertices[0].position
+ );
+ assert_eq!(
+ shadow_casters[0].triangles[1].positions[0],
+ mesh.vertices[parent_vertex_count].position
+ );
+ let first_shadow_triangles = shadow_casters[0].triangles.clone();
+ let first_parent_vertex = mesh.vertices[0];
+ let first_child_vertex = mesh.vertices[parent_vertex_count];
+ let first_mount = mounts[0][1].expect("initial child mount");
+ let first_fx_anchor = control_type4_world_from_effect(
+ identity_matrix(),
+ [1.0; 3],
+ identity_transform,
+ Some(first_mount),
+ control_effect_owner_node_pose(poses[0][1].as_ref(), true, 0)
+ .expect("mounted FX owner pose"),
+ )
+ .expect("initial FX anchor");
+
+ update_control_animation_pose_buffers(&mut instances, &mut poses, 225)
+ .expect("later active timeline sample");
+ let later_root_pose = poses[0][0].as_ref().expect("later root pose").poses[0];
+ assert_ne!(first_root_pose, later_root_pose);
+ update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
+ .expect("later geometry update");
+ refresh_dynamic_shadow_casters(
+ &mut shadow_casters,
+ &dynamic_shadow_casters,
+ &mesh,
+ &poses,
+ &mounts,
+ )
+ .expect("later caster geometry refresh");
+ let later_shadow_sphere = shadow_casters[0].sphere;
+ let later_shadow_triangles = shadow_casters[0].triangles.clone();
+ assert!(encloses_triangles(
+ later_shadow_sphere,
+ &later_shadow_triangles
+ ));
+ assert_ne!(first_shadow_triangles, shadow_casters[0].triangles);
+ assert_eq!(
+ shadow_casters[0].triangles[0].positions[0],
+ mesh.vertices[0].position
+ );
+ let later_parent_vertex = mesh.vertices[0];
+ let later_child_vertex = mesh.vertices[parent_vertex_count];
+ let later_mount = mounts[0][1].expect("later child mount");
+ let later_fx_anchor = control_type4_world_from_effect(
+ identity_matrix(),
+ [1.0; 3],
+ identity_transform,
+ Some(later_mount),
+ control_effect_owner_node_pose(poses[0][1].as_ref(), true, 0)
+ .expect("mounted FX owner pose"),
+ )
+ .expect("later FX anchor");
+
+ assert_ne!(first_parent_vertex.position, later_parent_vertex.position);
+ assert_ne!(first_parent_vertex.normal, later_parent_vertex.normal);
+ assert_ne!(first_mount.translation, later_mount.translation);
+ assert_eq!(first_child_vertex.position, first_mount.translation);
+ assert_eq!(later_child_vertex.position, later_mount.translation);
+ assert_ne!(first_fx_anchor[0][3], later_fx_anchor[0][3]);
+ assert_ne!(first_fx_anchor[1][3], later_fx_anchor[1][3]);
+
+ update_control_animation_pose_buffers(&mut instances, &mut poses, 225)
+ .expect("repeat the same timeline sample");
+ update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
+ .expect("repeat the same geometry update");
+ refresh_dynamic_shadow_casters(
+ &mut shadow_casters,
+ &dynamic_shadow_casters,
+ &mesh,
+ &poses,
+ &mounts,
+ )
+ .expect("repeat the same caster refresh");
+ assert_eq!(shadow_casters[0].sphere, later_shadow_sphere);
+ assert_eq!(shadow_casters[0].triangles, later_shadow_triangles);
+
+ update_control_animation_pose_buffers(&mut instances, &mut poses, 0)
+ .expect("return to the initial timeline sample");
+ update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
+ .expect("return to the initial geometry");
+ refresh_dynamic_shadow_casters(
+ &mut shadow_casters,
+ &dynamic_shadow_casters,
+ &mesh,
+ &poses,
+ &mounts,
+ )
+ .expect("rebuild the initial caster after a later pose");
+ assert_eq!(shadow_casters[0].sphere, first_shadow_sphere);
+ assert_eq!(shadow_casters[0].triangles, first_shadow_triangles);
+ }
+
+ #[test]
+ fn visible_exto_parent_refreshes_child_mount_without_parent_geometry() {
+ let make_record = |kind, immediate_child_count| fparkan_prototype::UnitComponentRecord {
+ archive_raw: [0; 32],
+ resource_raw: [0; 32],
+ kind,
+ parent_or_link: 1,
+ description_raw: [0; 32],
+ tail0: 0,
+ immediate_child_count,
+ };
+ let records = vec![
+ make_record(0, 1),
+ make_record(PROTOTYPE_TYPE_EXTO, 1),
+ make_record(PROTOTYPE_TYPE_EXTO, 0),
+ ];
+ let tree = unit_component_tree(&records).expect("valid three-record component tree");
+ assert_eq!(tree[2].parent_index, Some(1));
+ assert!(tree[1].parent_index.expect("parent has root") < 2);
+ assert!(unit_component_is_visible(1, Some(records[1].kind)));
+
+ let identity_transform = LegacyIron3dEulerTransform {
+ translation: [0.0; 3],
+ orientation_radians: [0.0; 3],
+ };
+ let mut parent_model = animated_socket_model();
+ parent_model.slots.clear();
+ parent_model.positions.clear();
+ parent_model.normals = None;
+ parent_model.uv0 = None;
+ parent_model.uv1 = None;
+ parent_model.indices.clear();
+ parent_model.batches.clear();
+ let initial_parent_pose =
+ node38_sampled_hierarchy_at_times(&parent_model, &[Some(AnimationTime(0.0)), None])
+ .expect("animated parent hierarchy without mesh geometry");
+ let parent_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
+ &parent_model,
+ identity_transform,
+ [1.0; 3],
+ &initial_parent_pose,
+ )
+ .expect("parent with no geometry projects successfully");
+ assert!(parent_mesh.vertices.is_empty());
+
+ let child_model = static_child_model();
+ let child_pose = node38_fallback_hierarchy(&child_model).expect("static child pose");
+ let initial_mount =
+ node38_pose_from_hierarchy(&initial_parent_pose, 1).expect("visible parent socket");
+ let mut mounts = vec![vec![None, None, Some(initial_mount)]];
+ let parent_mount = tree[2]
+ .parent_index
+ .filter(|parent_index| {
+ mounts[0][*parent_index].is_some()
+ || unit_component_is_visible(*parent_index, Some(records[*parent_index].kind))
+ })
+ .map(|parent_index| {
+ (
+ parent_index,
+ usize::try_from(records[2].parent_or_link).expect("valid EXTO parent socket"),
+ )
+ });
+ assert_eq!(parent_mount, Some((1, 1)));
+
+ let child_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
+ &child_model,
+ identity_transform,
+ [1.0; 3],
+ &child_pose,
+ initial_mount,
+ )
+ .expect("initial child mesh");
+ let mut mesh = VulkanStaticMesh {
+ vertices: child_mesh.vertices,
+ indices: child_mesh.indices,
+ draw_ranges: Vec::new(),
+ };
+ let control = socket_timeline_control();
+ let controls = vec![None, Some(control.clone()), None];
+ assert!(control_tree_chain_has_tag21(2, Some(&tree), &controls));
+ let mut instances = vec![vec![
+ None,
+ Some(ControlAnimationInstance {
+ gate_values: initial_control_gate_values(&control),
+ control,
+ model: parent_model,
+ state: ControlInstanceState::default(),
+ last_sampled_node_times: Vec::new(),
+ reported_unsupported_fallback: false,
+ }),
+ None,
+ ]];
+ let mut poses = vec![vec![None, Some(initial_parent_pose), Some(child_pose)]];
+ let geometry = vec![vec![
+ None,
+ None,
+ Some(DynamicComponentGeometry {
+ model: child_model,
+ root_transform: identity_transform,
+ owner_scale: [1.0; 3],
+ parent_mount,
+ vertex_start: 0,
+ vertex_count: mesh.vertices.len(),
+ }),
+ ]];
+
+ update_control_animation_pose_buffers(&mut instances, &mut poses, 0)
+ .expect("initial visible parent timeline sample");
+ update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
+ .expect("initial child mount refresh");
+ let initial_child_vertex = mesh.vertices[0].position;
+ let initial_child_mount = mounts[0][2].expect("initial child mount");
+ assert_eq!(initial_child_vertex, initial_child_mount.translation);
+
+ update_control_animation_pose_buffers(&mut instances, &mut poses, 225)
+ .expect("later visible parent timeline sample");
+ update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
+ .expect("child mount refresh without parent geometry descriptor");
+ let later_child_mount = mounts[0][2].expect("later child mount");
+ assert_ne!(
+ initial_child_mount.translation,
+ later_child_mount.translation
+ );
+ assert_eq!(mesh.vertices[0].position, later_child_mount.translation);
+ }
+
+ #[test]
+ fn dynamic_shadow_sphere_union_keeps_the_containing_input_sphere() {
+ let small = ShadowSphere {
+ center: [0.0; 3],
+ radius: 1.0,
+ };
+ let large = ShadowSphere {
+ center: [1.0, 0.0, 0.0],
+ radius: 10.0,
+ };
+
+ assert_eq!(
+ shadow_sphere_enclosing_current_geometry(small, large, &[])
+ .expect("large current native sphere contains baseline"),
+ large
+ );
+ assert_eq!(
+ shadow_sphere_enclosing_current_geometry(large, small, &[])
+ .expect("large baseline sphere contains current native sphere"),
+ large
+ );
+ }
+
+ fn endpoint_op3_command() -> FxCommand {
+ let mut body = vec![0; 0xc4];
+ write_effect_u32(&mut body, 0, 2);
+ write_effect_f32(&mut body, 0x04, 0.0);
+ write_effect_f32(&mut body, 0x08, 1.0);
+ write_effect_f32(&mut body, 0x0c, 1.0);
+ write_effect_f32(&mut body, 0x10, 0.0);
+ write_effect_f32(&mut body, 0x14, 1.0);
+ write_effect_f32(&mut body, 0x18, 1.0);
+ write_effect_f32(&mut body, 0x1c, 0.0);
+ write_effect_f32(&mut body, 0x20, 1.0);
+ write_effect_vec3(&mut body, 0x24, [0.0; 3]);
+ write_effect_vec3(&mut body, 0x30, [0.0; 3]);
+ write_effect_vec3(&mut body, 0x3c, [1.0; 3]);
+ write_effect_vec3(&mut body, 0x48, [0.0, 0.0, 1.0]);
+ write_effect_vec3(&mut body, 0x54, [0.0, 0.0, 1.0]);
+ write_effect_vec3(&mut body, 0x60, [1.0; 3]);
+ write_effect_vec3(&mut body, 0x6c, [1.0; 3]);
+ write_effect_vec3(&mut body, 0x78, [1.0; 3]);
+ body[0x84..0x84 + 11].copy_from_slice(b"effects.rlb");
+ let name = b"f_smalllight_y";
+ body[0xa4..0xa4 + name.len()].copy_from_slice(name);
+ FxCommand {
+ word: 3,
+ opcode: FxOpcode::Op3,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
+ }
+ }
+
+ fn endpoint_op1_command(effect_type: u32, start: f32, end: f32) -> FxCommand {
+ let mut body = vec![0; 132];
+ write_effect_u32(&mut body, 0, effect_type);
+ write_effect_f32(&mut body, 4, start);
+ write_effect_f32(&mut body, 8, end);
+ write_effect_vec3(&mut body, 12, [1.0, 0.0, 0.0]);
+ write_effect_vec3(&mut body, 24, [3.0, 0.0, 0.0]);
+ write_effect_vec3(&mut body, 36, [0.0, 1.0, 0.0]);
+ write_effect_vec3(&mut body, 48, [0.0, 1.0, 0.0]);
+ for (index, value) in [0.0, 1.0, 0.5, 1.0].into_iter().enumerate() {
+ write_effect_f32(&mut body, 60 + index * 4, value);
+ }
+ for (index, value) in [0.5, 0.0, 1.0, 1.0].into_iter().enumerate() {
+ write_effect_f32(&mut body, 76 + index * 4, value);
+ }
+ write_effect_f32(&mut body, 108, 10.0);
+ write_effect_f32(&mut body, 112, 20.0);
+ write_effect_f32(&mut body, 116, 1.0);
+ write_effect_vec3(&mut body, 120, [1.0, 0.5, 0.25]);
+ FxCommand {
+ word: 1,
+ opcode: FxOpcode::Op1,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
+ }
+ }
+
+ fn endpoint_gener_ball_command(opcode: FxOpcode) -> FxCommand {
+ let is_op9 = opcode == FxOpcode::Op9;
+ let mut body = vec![0; if is_op9 { 0xcc } else { 0xc8 }];
+ write_effect_u32(&mut body, 0, if is_op9 { 2 } else { 0 });
+ write_effect_f32(&mut body, 0x04, 0.0);
+ write_effect_f32(&mut body, 0x08, 1.0);
+ write_effect_f32(&mut body, 0x0c, 1.0);
+ write_effect_f32(&mut body, 0x10, 0.0);
+ write_effect_f32(&mut body, 0x14, 1.0);
+ write_effect_f32(&mut body, 0x18, 1.0);
+ write_effect_f32(&mut body, 0x1c, 0.75);
+ write_effect_f32(&mut body, 0x20, 1.0);
+ write_effect_vec3(&mut body, 0x24, [0.0; 3]);
+ write_effect_vec3(&mut body, 0x30, [0.0; 3]);
+ write_effect_vec3(&mut body, 0x3c, [1.0; 3]);
+ write_effect_vec3(&mut body, 0x48, [0.0, 1.0, 0.0]);
+ write_effect_vec3(&mut body, 0x54, [0.0, 1.0, 0.0]);
+ write_effect_vec3(&mut body, 0x60, [1.0; 3]);
+ write_effect_vec3(&mut body, 0x6c, [1.0; 3]);
+ write_effect_vec3(&mut body, 0x78, [1.0; 3]);
+ body[0x84..0x84 + 12].copy_from_slice(b"material.lib");
+ let name = if is_op9 {
+ b"laser_y_hit".as_slice()
+ } else {
+ b"glow_y"
+ };
+ body[0xa4..0xa4 + name.len()].copy_from_slice(name);
+ if is_op9 {
+ write_effect_u32(&mut body, 0xc4, 0);
+ } else {
+ write_effect_f32(&mut body, 0xc4, 500.0);
+ }
+ FxCommand {
+ word: if is_op9 { 9 } else { 4 },
+ opcode,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
}
}
+ fn endpoint_op8_command(pool_count: u32) -> FxCommand {
+ let mut body = vec![0; 0xf4];
+ write_effect_u32(&mut body, 0x20, pool_count);
+ FxCommand {
+ word: 8,
+ opcode: FxOpcode::Op8,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
+ }
+ }
+
+ fn endpoint_op7_command(
+ mode: u32,
+ material_phase_factor: f32,
+ acceleration: f32,
+ pool_count: u32,
+ copies_per_group: u32,
+ ) -> FxCommand {
+ let mut body = vec![0; 0xCC];
+ write_effect_u32(&mut body, 0x00, mode);
+ write_effect_f32(&mut body, 0x1C, material_phase_factor);
+ write_effect_u32(&mut body, 0x20, pool_count);
+ write_effect_u32(&mut body, 0x24, copies_per_group);
+ write_effect_f32(&mut body, 0x88, acceleration);
+ FxCommand {
+ word: 7,
+ opcode: FxOpcode::Op7,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
+ }
+ }
+
+ fn endpoint_effect_frame(command_index: usize) -> fparkan_fx::fx_visual::EffectQuadFrame {
+ fparkan_fx::fx_visual::EffectQuadFrame {
+ command_index,
+ opcode: FxOpcode::Op7,
+ material: fparkan_fx::environment::EffectResource {
+ archive: "material.lib".to_owned(),
+ name: "smoke_yg".to_owned(),
+ },
+ vertices: [[0.0; 3]; 4],
+ uv: [[0.0; 2]; 4],
+ color: [1.0; 4],
+ material_phase: 0.25,
+ elapsed_seconds: 0.5,
+ normalized_time: 0.5,
+ active: true,
+ native_flag_base: None,
+ }
+ }
+
+ fn strings(values: &[&str]) -> Vec<String> {
+ values.iter().map(|value| (*value).to_string()).collect()
+ }
+
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_part1_prepares_every_mission() {
@@ -9287,6 +13084,53 @@ mod tests {
}
#[test]
+ #[ignore = "requires installed GOG resources; set FPARKAN_GAME_ROOT"]
+ fn gog_terrain_detail_uses_multitexture_animation_block() {
+ let root = std::env::var_os("FPARKAN_GAME_ROOT")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
+ let mut loader = StandaloneWearMaterialLoader::new(&root);
+ let mut mesh = VulkanStaticMesh::smoke_triangle();
+ mesh.draw_ranges[0].material_index = 0x0102;
+ let mut materials = Vec::new();
+ let mut animations = Vec::new();
+ let selections = static_preview_terrain_base_materials(
+ &mut loader,
+ &root,
+ "DATA/MAPS/Tut_2/Land.msh",
+ &mesh,
+ &mut materials,
+ &mut animations,
+ None,
+ )
+ .expect("prepare real terrain material layers");
+ let binding = &mut animations[0];
+ let sample = binding.detail.as_mut().unwrap().sample(0, 2000).unwrap();
+ assert_eq!(
+ sample.texture_raw.split(|&byte| byte == 0).next().unwrap(),
+ b"L09M.0"
+ );
+ let selected = &materials[usize::from(selections[0].1)];
+ let path = root.join("DATA/MAPS/Tut_2/Land2.wea");
+ let (detail, _) = load_standalone_vulkan_texture(&mut loader, &path, 2, 1).unwrap();
+ let (overlay_detail, _) = load_standalone_vulkan_texture(&mut loader, &path, 1, 1).unwrap();
+ assert!(
+ selected.detail_texture.as_ref() == Some(&detail),
+ "initial detail descriptor must use the multitexture phase"
+ );
+ assert!(
+ selected.overlay_detail_texture.as_ref() == Some(&overlay_detail),
+ "initial overlay-detail descriptor must use the multitexture phase"
+ );
+ assert!(
+ sample_material_animations(&mut animations, &materials, 2000)
+ .unwrap()
+ .stage_phases
+ .is_empty()
+ );
+ }
+
+ #[test]
fn draw_range_reorder_remaps_all_material_animation_bindings() {
let range = |first_index, material_index| VulkanStaticDrawRange {
first_index,
@@ -9323,9 +13167,12 @@ mod tests {
};
let mut bindings = vec![MaterialAnimationBinding {
initial_phase_index: 0,
+ normalized_phase_override: None,
+ ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices: vec![10],
range_indices: vec![0, 2],
+ pipeline_category: 0,
}],
base: empty_binding(),
detail: None,
@@ -9382,14 +13229,34 @@ mod tests {
}
#[test]
- fn native_cpu_shaded_msh_alpha_override_uses_only_the_constant_batch_rule() {
- assert_eq!(native_msh_diffuse_alpha_override(0x0148, false), Some(0.0));
- assert_eq!(native_msh_diffuse_alpha_override(0x0108, false), None);
- assert_eq!(native_msh_diffuse_alpha_override(0x0118, false), None);
- assert_eq!(native_msh_diffuse_alpha_override(0x0058, false), None);
- assert_eq!(native_msh_diffuse_alpha_override(0x0148, true), None);
- assert_eq!(native_msh_diffuse_alpha_override(0x0058, true), None);
- assert_eq!(native_msh_diffuse_alpha_override(0x0040, false), None);
+ fn cpu_msh_portal_alpha_updates_with_distance_and_preserves_type16() {
+ let mut mesh = VulkanStaticMesh::smoke_triangle();
+ for flags in [0x0148, 0x0108, 0x0118, 0x0058] {
+ mesh.draw_ranges[0].batch_flags = flags;
+ assert_eq!(
+ native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -1.0], 1.04),
+ Ok(Some(0.0))
+ );
+ assert_eq!(
+ native_msh_diffuse_alpha_override(&mesh, 0, true, [0.0, -0.55, -1.0], 1.04),
+ Ok(None)
+ );
+ }
+ mesh.draw_ranges[0].batch_flags = 0x0118;
+ let alpha = native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -25.0], 1.04)
+ .unwrap()
+ .unwrap();
+ assert!((alpha - 0.5).abs() < 0.000_001);
+ assert_eq!(
+ native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -60.0], 1.04),
+ Ok(Some(1.0))
+ );
+ assert!(native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0; 3], 0.0).is_err());
+ mesh.draw_ranges[0].batch_flags = 0x0040;
+ assert_eq!(
+ native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0; 3], 1.04),
+ Ok(None)
+ );
}
#[test]
@@ -9397,6 +13264,7 @@ mod tests {
let variant = MaterialVariantSet {
phase_material_indices: vec![10, 11],
range_indices: vec![3],
+ pipeline_category: 1,
};
let category_for = |material_index| match material_index {
10 => Some(1),
@@ -9471,36 +13339,33 @@ mod tests {
}
#[test]
- fn animated_shadow_bounds_rebuild_from_native_baseline() {
- let baseline = ShadowSphere {
- center: [0.0; 3],
- radius: 1.0,
- };
- let pose_a = [shadow_triangle(
- [[0.5, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.5]],
- 0,
- )];
- let pose_b = [shadow_triangle(
- [[3.0, 0.0, 0.0], [4.0, 0.0, 0.0], [3.0, 1.0, 0.0]],
- 0,
- )];
-
- let first_a =
- shadow_sphere_enclosing_triangles(baseline, &pose_a).expect("initial animated bounds");
- assert_eq!(first_a, baseline);
- let at_b =
- shadow_sphere_enclosing_triangles(baseline, &pose_b).expect("expanded animated bounds");
- assert!((at_b.center[0] - 1.5).abs() < 1.0e-5);
- assert!((at_b.radius - 2.5).abs() < 1.0e-5);
- assert!(length3(at_b.center).expect("finite sphere center") + 1.0 <= at_b.radius);
- for point in pose_b.iter().flat_map(|triangle| triangle.positions) {
- assert!(
- length3(sub3(point, at_b.center)).expect("finite point distance") <= at_b.radius
- );
- }
- let second_a = shadow_sphere_enclosing_triangles(baseline, &pose_a)
- .expect("return to first animated pose");
- assert_eq!(second_a, first_a);
+ fn projected_shadow_alone_uses_decal_depth_bias() {
+ let shadow = projected_shadow_pipeline_state();
+ assert_eq!(shadow.blend, LegacyBlendMode::SourceAlpha);
+ assert_eq!(shadow.depth, LegacyDepthMode::TestReadOnly);
+ assert_eq!(shadow.depth_bias, LegacyDepthBias::ProjectedDecal);
+ assert_eq!(
+ world_pipeline_state(LegacyBlendMode::Opaque, LegacyDepthMode::TestWrite).depth_bias,
+ LegacyDepthBias::Disabled
+ );
+ }
+
+ #[test]
+ fn shadow_atlas_texture_equality_includes_pixels_and_mip_layout() {
+ let uploaded = shadow_atlas_texture();
+ assert_eq!(shadow_atlas_texture(), uploaded);
+
+ let mut changed_rgba = uploaded.clone();
+ changed_rgba.rgba8[0] ^= 1;
+ assert_ne!(changed_rgba, uploaded);
+
+ let mut changed_mip_pixels = uploaded.clone();
+ changed_mip_pixels.mip_levels[0].rgba8[0] ^= 1;
+ assert_ne!(changed_mip_pixels, uploaded);
+
+ let mut changed_mip = uploaded.clone();
+ changed_mip.mip_levels[0].width -= 1;
+ assert_ne!(changed_mip, uploaded);
}
#[test]
@@ -9573,6 +13438,7 @@ mod tests {
control_clock_ms: None,
validation: false,
readback_out: None,
+ readback_sequence_out: None,
preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None,
static_animation_frame: None,
@@ -9597,44 +13463,843 @@ 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 = ControlEffectSlot {
+ 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,
+ fixed_sky_uv_ms: None,
+ };
+
+ 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 {
+ terrain_lod: None,
+ dynamic_vertex_start: 3,
+ 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],
+ control_sound_slots: Vec::new(),
+ unsupported_control_sounds: 0,
+ component_pose_buffers: vec![vec![None]],
+ component_mount_poses: vec![vec![None]],
+ dynamic_component_geometry: Vec::new(),
+ building_collision_bindings: Vec::new(),
+ control_animation_instances: Vec::new(),
+ dynamic_shadow_casters: Vec::new(),
+ sun_occlusion_range_indices: Vec::new(),
+ shadow_casters: Vec::new(),
+ shadow_receivers: Vec::new(),
+ shadow_material_index: 0,
+ terrain: terrain.clone(),
+ camera: view_camera.vulkan_camera(),
+ view_camera,
+ captured_viewport: None,
+ free_camera: Some(free_camera),
+ camera_mode: "test",
+ mesh_components: 0,
+ terrain_components: 0,
+ };
+ let app = StaticVulkanApp::new(
+ preview,
+ Some(environment),
+ None,
+ EnvironmentGpuRange {
+ range_index: 1,
+ vertex_start: 3,
+ vertex_capacity: 0,
+ material_index: 0,
+ },
+ 3,
+ 3,
+ None,
+ 2,
+ "clock-test",
+ 1,
+ None,
+ None,
+ false,
+ control_clock_target_ms,
+ StartupTimings::default(),
+ );
+ (app, key)
+ }
+
+ fn control_random_snapshot(
+ state: &ControlEffectRandomState,
+ ) -> (
+ u32,
+ u32,
+ ControlEffectRandomSample,
+ Option<SceneSampleId>,
+ Option<u32>,
+ ) {
+ (
+ state.phase_and_z_rng.state,
+ state.xy_rng.state,
+ state.sample,
+ state.last_update_id,
+ state.last_update_clock_ms,
+ )
}
#[test]
- fn replay_clock_requires_at_least_two_requested_frames() {
- for frame_count in ["0", "1"] {
- assert!(Args::parse(&strings(&[
- "--root",
- "testdata/IS",
- "--mission",
- "MISSIONS/Autodemo.00/data.tma",
- "--frames",
- frame_count,
- "--control-clock-ms",
- "500",
- ]))
- .unwrap_err()
- .contains("--frames of at least 2"));
+ fn live_clock_samples_have_increasing_ids_and_monotonic_time() {
+ let (mut app, _) = clock_test_app(None);
+ let first = app.sample_frame_clock();
+ let second = app.sample_frame_clock();
+ assert_eq!(first.id, SceneSampleId::Live(0));
+ assert_eq!(second.id, SceneSampleId::Live(1));
+ assert!(second.time_ms >= first.time_ms);
+ assert!(second.delta_seconds >= 0.0);
+ }
+
+ #[test]
+ fn zero_extent_resume_does_not_add_minimized_time_to_live_clock() {
+ let (mut app, _) = clock_test_app(None);
+ let started = Instant::now();
+ app.simulation_last_tick = started;
+ app.pause_live_clock_at(started);
+ assert!(app.live_clock_paused_for_zero_extent);
+
+ let restored = started + Duration::from_secs(600);
+ app.resume_live_clock_at(restored);
+ assert!(!app.live_clock_paused_for_zero_extent);
+ assert_eq!(app.simulation_last_tick, restored);
+
+ let resumed = app.sample_live_frame_clock_at(restored + Duration::from_millis(16));
+ assert_eq!(resumed.id, SceneSampleId::Live(0));
+ assert_eq!(resumed.time_ms, 16);
+ assert!((resumed.delta_seconds - 0.016).abs() < 1.0e-6);
+ assert_eq!(app.simulation_elapsed, Duration::from_millis(16));
+ }
+
+ #[test]
+ fn live_cpu_updates_sample_materials_effect_rng_and_environment_without_renderer() {
+ let (mut app, key) = clock_test_app(None);
+ app.render_scene_update()
+ .expect("an early redraw without a packet is a no-op");
+ assert!(app.pending_scene_update.is_none());
+ assert_eq!(app.frames_presented, 0);
+ assert_eq!(app.simulation_update_generation, 0);
+
+ let mut first = app.sample_frame_clock();
+ assert_eq!(first.id, SceneSampleId::Live(0));
+ first.time_ms = 0;
+ first.delta_seconds = 0.1;
+ app.prepare_scene_update(first, [1280.0, 720.0])
+ .expect("prepare a live CPU frame without a renderer");
+ assert!(app.renderer.is_none());
+ let first_frame = app.pending_scene_update.as_ref().expect("sampled frame");
+ assert_eq!(
+ first_frame.material_update.active_materials[&0].phase_index,
+ 0
+ );
+ assert!(!first_frame
+ .environment
+ .as_ref()
+ .unwrap()
+ .sky_mesh
+ .vertices
+ .is_empty());
+ let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
+ assert_eq!(first_random.3, Some(SceneSampleId::Live(0)));
+ assert_ne!(first_random.0, 0);
+ assert_ne!(first_random.1, 0);
+ let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
+ assert!(first_environment_time > 0.0);
+ let first_vertices = app.mesh.vertices.clone();
+
+ for _ in 0..2 {
+ app.render_scene_update()
+ .expect("renderer-free redraw does not sample simulation state");
+ }
+ assert_eq!(
+ control_random_snapshot(&app.control_effect_random_states[&key]),
+ first_random
+ );
+ assert_eq!(
+ app.environment.as_ref().unwrap().time_seconds,
+ first_environment_time
+ );
+ assert_eq!(app.mesh.vertices, first_vertices);
+ assert_eq!(app.simulation_update_generation, 1);
+
+ let mut next = app.sample_frame_clock();
+ assert_eq!(next.id, SceneSampleId::Live(1));
+ next.time_ms = 500;
+ next.delta_seconds = 0.5;
+ app.prepare_scene_update(next, [1280.0, 720.0])
+ .expect("replace the latest live packet");
+ assert_eq!(app.simulation_update_generation, 2);
+ assert_eq!(
+ app.pending_scene_update.as_ref().unwrap().clock.id,
+ SceneSampleId::Live(1)
+ );
+ assert_ne!(
+ control_random_snapshot(&app.control_effect_random_states[&key]),
+ first_random
+ );
+ assert!(app.environment.as_ref().unwrap().time_seconds > first_environment_time);
+ assert_eq!(
+ app.pending_scene_update
+ .as_ref()
+ .unwrap()
+ .material_update
+ .active_materials[&0]
+ .phase_index,
+ 1
+ );
+ assert_eq!(
+ app.pending_scene_update.as_ref().unwrap().clock.time_ms,
+ 500
+ );
+ assert_eq!(
+ app.pending_scene_update
+ .as_ref()
+ .unwrap()
+ .environment
+ .as_ref()
+ .unwrap()
+ .sky_mesh
+ .vertices[0]
+ .uv[2],
+ [0.01, 0.0]
+ );
+ app.environment.as_mut().unwrap().fixed_sky_uv_ms = Some(25_000);
+ next.id = SceneSampleId::Live(2);
+ next.time_ms = 10_000;
+ app.prepare_scene_update(next, [1280.0, 720.0]).unwrap();
+ assert_eq!(
+ app.pending_scene_update
+ .as_ref()
+ .unwrap()
+ .environment
+ .as_ref()
+ .unwrap()
+ .sky_mesh
+ .vertices[0]
+ .uv[2],
+ [0.5, 0.0]
+ );
+ }
+
+ #[test]
+ fn live_pending_resize_reuses_225ms_packet_across_zero_extent_pause() {
+ let (mut app, key) = clock_test_app(None);
+ let started = Instant::now();
+ app.simulation_last_tick = started;
+ app.pressed_keys.insert(KeyCode::KeyE);
+
+ let first = app.sample_live_frame_clock_at(started + Duration::from_millis(225));
+ assert_eq!(first.id, SceneSampleId::Live(0));
+ assert_eq!(first.time_ms, 225);
+ assert!((first.delta_seconds - 0.225).abs() < 1.0e-6);
+ app.prepare_scene_update(first, [1280.0, 720.0])
+ .expect("sample one 225 ms live packet");
+ let first_frame = app.pending_scene_update.as_ref().unwrap();
+ let first_camera = first_frame.camera;
+ let expected_resized_camera = app
+ .free_camera
+ .expect("free-flight input controller")
+ .preview_frame(4.0 / 3.0);
+ let first_effect_updates = first_frame.effect_updates.clone();
+ let first_material_phase = first_frame.material_update.active_materials[&0].phase_index;
+ let weather = WeatherInput {
+ rain: WeatherState::new(true, 0.03, [0.7, 0.8, 1.0, 0.6]),
+ snow: WeatherState::new(true, 0.03, [0.9, 0.95, 1.0, 0.8]),
+ ..WeatherInput::clear()
+ };
+ let mut weather_system = EnvironmentSystem::new(0xC1_225);
+ let first_environment_frame = weather_system.update(
+ 0.225,
+ weather,
+ environment_camera_for_preview(&first_camera, 16.0 / 9.0, [1280.0, 720.0]),
+ );
+ let mut precipitation_counts = [0usize; 2];
+ for primitive in &first_environment_frame.primitives {
+ if let EnvironmentPrimitive::Particle { kind, .. } = primitive {
+ let slot = match kind {
+ PrecipitationKind::Rain => 0,
+ PrecipitationKind::Snow => 1,
+ };
+ precipitation_counts[slot] += 1;
+ }
+ }
+ assert!(precipitation_counts.iter().all(|count| *count >= 17));
+ app.pending_scene_update
+ .as_mut()
+ .unwrap()
+ .environment
+ .as_mut()
+ .unwrap()
+ .environment_frame = first_environment_frame.clone();
+ let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
+ let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
+ let first_environment_system_time =
+ app.environment.as_ref().unwrap().environment.time_seconds();
+ let first_vertices = app.mesh.vertices.clone();
+ assert!((first_camera.position[2] - 5.4).abs() < 1.0e-5);
+ assert_eq!(app.simulation_elapsed, Duration::from_millis(225));
+ assert_eq!(app.simulation_update_generation, 1);
+
+ app.pause_live_clock_at(started + Duration::from_millis(250));
+ let restored = started + Duration::from_secs(10);
+ app.resume_live_clock_at(restored);
+ app.free_camera.as_mut().unwrap().look_delta(64.0, -24.0);
+ let controller_after_input = app.free_camera.unwrap();
+ let retry = app.pending_or_sample_frame_clock();
+ assert_eq!(retry, first);
+ assert_eq!(app.simulation_update_generation, 1);
+ assert_eq!(app.simulation_elapsed, Duration::from_millis(225));
+ assert_eq!(app.simulation_last_tick, restored);
+
+ app.prepare_scene_update(retry, [1024.0, 768.0])
+ .expect("refresh only the pending packet's viewport");
+ let retry_frame = app.pending_scene_update.as_ref().unwrap();
+ assert_eq!(retry_frame.clock, first);
+ assert_eq!(retry_frame.viewport, [1024.0, 768.0]);
+ assert_eq!(retry_frame.aspect, 4.0 / 3.0);
+ assert_eq!(retry_frame.camera.position, first_camera.position);
+ assert_eq!(retry_frame.camera.forward, first_camera.forward);
+ assert_eq!(retry_frame.camera.right, first_camera.right);
+ assert_eq!(retry_frame.camera.up, first_camera.up);
+ assert_eq!(
+ retry_frame.camera.position,
+ expected_resized_camera.position
+ );
+ assert_eq!(retry_frame.camera.forward, expected_resized_camera.forward);
+ assert_eq!(retry_frame.camera.right, expected_resized_camera.right);
+ assert_eq!(retry_frame.camera.up, expected_resized_camera.up);
+ assert!(
+ (retry_frame.camera.vertical_fov - expected_resized_camera.vertical_fov).abs() < 1.0e-6
+ );
+ assert!(retry_frame
+ .camera
+ .clip_from_world
+ .iter()
+ .zip(expected_resized_camera.clip_from_world)
+ .all(|(actual, expected)| (actual - expected).abs() < 1.0e-5));
+ assert_ne!(
+ retry_frame.camera.clip_from_world,
+ first_camera.clip_from_world
+ );
+ assert_eq!(app.free_camera, Some(controller_after_input));
+ assert_eq!(retry_frame.effect_updates, first_effect_updates);
+ assert_eq!(
+ retry_frame.material_update.active_materials[&0].phase_index,
+ first_material_phase
+ );
+ let retry_environment_frame = &retry_frame.environment.as_ref().unwrap().environment_frame;
+ assert_eq!(
+ retry_environment_frame.point_lights,
+ first_environment_frame.point_lights
+ );
+ assert_eq!(
+ retry_environment_frame.sounds,
+ first_environment_frame.sounds
+ );
+ let mut reprojected_counts = [0usize; 2];
+ for (sampled, refreshed) in first_environment_frame
+ .primitives
+ .iter()
+ .zip(&retry_environment_frame.primitives)
+ {
+ let (
+ EnvironmentPrimitive::Particle {
+ kind: sampled_kind,
+ position: sampled_position,
+ velocity: sampled_velocity,
+ size: sampled_size,
+ screen: sampled_screen,
+ ..
+ },
+ EnvironmentPrimitive::Particle {
+ kind,
+ position,
+ velocity,
+ size,
+ screen,
+ ..
+ },
+ ) = (sampled, refreshed)
+ else {
+ panic!("rain and snow fixture contains only precipitation particles");
+ };
+ assert_eq!(kind, sampled_kind);
+ assert_eq!(position, sampled_position);
+ assert_eq!(velocity, sampled_velocity);
+ assert_ne!(screen, sampled_screen);
+ let slot = match kind {
+ PrecipitationKind::Rain => 0,
+ PrecipitationKind::Snow => 1,
+ };
+ reprojected_counts[slot] += 1;
+ assert_ne!(size, sampled_size);
+ }
+ assert!(reprojected_counts.iter().all(|count| *count >= 17));
+ assert_eq!(
+ control_random_snapshot(&app.control_effect_random_states[&key]),
+ first_random
+ );
+ assert_eq!(
+ app.environment.as_ref().unwrap().time_seconds,
+ first_environment_time
+ );
+ assert_eq!(
+ app.environment.as_ref().unwrap().environment.time_seconds(),
+ first_environment_system_time
+ );
+ assert_eq!(app.mesh.vertices, first_vertices);
+
+ assert_eq!(app.record_presented_scene_frame(), 0);
+ let next = app.sample_live_frame_clock_at(restored + Duration::from_millis(16));
+ assert_eq!(next.id, SceneSampleId::Live(1));
+ assert_eq!(next.time_ms, 241);
+ assert!((next.delta_seconds - 0.016).abs() < 1.0e-6);
+ assert_eq!(app.simulation_elapsed, Duration::from_millis(241));
+ app.prepare_scene_update(next, [1024.0, 768.0])
+ .expect("the next sample adopts mouse input accumulated during retry");
+ let next_camera = app.pending_scene_update.as_ref().unwrap().camera;
+ assert_ne!(next_camera.forward, first_camera.forward);
+ assert_eq!(next_camera.forward, controller_after_input.forward());
+ }
+
+ #[test]
+ fn replay_retries_keep_one_packet_until_presented_then_advance_with_new_viewport() {
+ let (mut app, key) = clock_test_app(Some(500));
+ app.render_scene_update()
+ .expect("redraw without a pending packet does nothing");
+ assert_eq!(app.simulation_update_generation, 0);
+
+ let first = app.sample_frame_clock();
+ assert_eq!(
+ first,
+ FrameClockSample {
+ id: SceneSampleId::Replay(0),
+ time_ms: 0,
+ delta_seconds: 0.0,
+ }
+ );
+ app.prepare_scene_update(first, [1280.0, 720.0])
+ .expect("prepare the first replay frame");
+ let first_frame = app.pending_scene_update.as_ref().unwrap();
+ let first_camera = first_frame.camera;
+ let first_viewport = first_frame.viewport;
+ let first_aspect = first_frame.aspect;
+ let first_effect_updates = first_frame.effect_updates.clone();
+ let first_environment_mesh = first_frame.environment.as_ref().unwrap().sky_mesh.clone();
+ let first_environment_frame = first_frame
+ .environment
+ .as_ref()
+ .unwrap()
+ .environment_frame
+ .clone();
+ let first_material_phase = first_frame.material_update.active_materials[&0].phase_index;
+ let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
+ let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
+ let first_vertices = app.mesh.vertices.clone();
+ assert_eq!(first_material_phase, 0);
+ assert_eq!(first_random.3, Some(SceneSampleId::Replay(0)));
+ assert_eq!(first_random.4, Some(0));
+ assert!(!first_environment_mesh.vertices.is_empty());
+
+ for _ in 0..2 {
+ app.render_scene_update()
+ .expect("renderer-free replay redraw is a retry");
+ let retry = app.sample_frame_clock();
+ assert_eq!(retry.id, SceneSampleId::Replay(0));
+ app.prepare_scene_update(retry, [1024.0, 768.0])
+ .expect("a resize retry reuses the current replay packet");
+ }
+ let retry_frame = app.pending_scene_update.as_ref().unwrap();
+ assert_eq!(retry_frame.camera.position, first_camera.position);
+ assert_eq!(retry_frame.camera.forward, first_camera.forward);
+ assert_eq!(retry_frame.camera.up, first_camera.up);
+ assert_eq!(retry_frame.viewport, [1024.0, 768.0]);
+ assert_eq!(retry_frame.aspect, 4.0 / 3.0);
+ assert_ne!(
+ retry_frame.camera.clip_from_world,
+ first_camera.clip_from_world
+ );
+ assert_ne!(retry_frame.viewport, first_viewport);
+ assert_ne!(retry_frame.aspect, first_aspect);
+ assert_eq!(retry_frame.effect_updates, first_effect_updates);
+ assert_eq!(
+ retry_frame.environment.as_ref().unwrap().sky_mesh,
+ first_environment_mesh
+ );
+ assert_eq!(
+ retry_frame.environment.as_ref().unwrap().environment_frame,
+ first_environment_frame
+ );
+ assert_eq!(
+ retry_frame.material_update.active_materials[&0].phase_index,
+ 0
+ );
+ assert_eq!(
+ control_random_snapshot(&app.control_effect_random_states[&key]),
+ first_random
+ );
+ assert_eq!(
+ app.environment.as_ref().unwrap().time_seconds,
+ first_environment_time
+ );
+ assert_eq!(app.mesh.vertices, first_vertices);
+ assert_eq!(app.frames_presented, 0);
+
+ assert_eq!(app.record_presented_scene_frame(), 0);
+ assert_eq!(app.frames_presented, 1);
+ assert!(app.pending_scene_update.is_none());
+ let next = app.sample_frame_clock();
+ assert_eq!(next.id, SceneSampleId::Replay(1));
+ assert_eq!(next.time_ms, 500);
+ assert_eq!(next.delta_seconds, 0.5);
+ app.prepare_scene_update(next, [1024.0, 768.0])
+ .expect("the next presented replay frame samples the resized view");
+ let next_frame = app.pending_scene_update.as_ref().unwrap();
+ assert_eq!(next_frame.viewport, [1024.0, 768.0]);
+ assert_eq!(next_frame.aspect, 4.0 / 3.0);
+ assert_ne!(
+ next_frame.camera.clip_from_world,
+ first_camera.clip_from_world
+ );
+ assert_eq!(
+ next_frame.material_update.active_materials[&0].phase_index,
+ 1
+ );
+ assert!(
+ (app.environment.as_ref().unwrap().time_seconds - first_environment_time - 0.5).abs()
+ < 1.0e-6
+ );
+ let next_random = control_random_snapshot(&app.control_effect_random_states[&key]);
+ assert_eq!(next_random.3, Some(SceneSampleId::Replay(1)));
+ assert_eq!(next_random.4, Some(500));
+ assert_ne!(next_random, first_random);
+ let next_camera = next_frame.camera;
+ let next_environment_mesh = next_frame.environment.as_ref().unwrap().sky_mesh.clone();
+ let next_environment_frame = next_frame
+ .environment
+ .as_ref()
+ .unwrap()
+ .environment_frame
+ .clone();
+
+ app.render_scene_update()
+ .expect("renderer-free draw does not advance the replay packet");
+ let repeated = app.sample_frame_clock();
+ assert_eq!(repeated.id, SceneSampleId::Replay(1));
+ app.prepare_scene_update(repeated, [800.0, 600.0])
+ .expect("duplicate replay frame is idempotent");
+ let repeated_frame = app.pending_scene_update.as_ref().unwrap();
+ assert_eq!(repeated_frame.viewport, [800.0, 600.0]);
+ assert_eq!(repeated_frame.aspect, 4.0 / 3.0);
+ assert_eq!(repeated_frame.camera, next_camera);
+ assert_eq!(
+ repeated_frame.environment.as_ref().unwrap().sky_mesh,
+ next_environment_mesh
+ );
+ assert_eq!(
+ repeated_frame
+ .environment
+ .as_ref()
+ .unwrap()
+ .environment_frame,
+ next_environment_frame
+ );
+ assert_eq!(
+ control_random_snapshot(&app.control_effect_random_states[&key]),
+ next_random
+ );
+ assert_eq!(
+ app.environment.as_ref().unwrap().time_seconds,
+ first_environment_time + 0.5
+ );
+ assert_eq!(app.frames_presented, 1);
+ }
+
+ #[test]
+ fn replay_environment_delta_uses_last_applied_clock_and_keeps_duplicates_at_zero() {
+ let initial = control_clock_ms_for_frame(500, 0, 101);
+ assert_eq!(initial, 0);
+ assert_eq!(replay_environment_delta_seconds(0, initial), 0.0);
+
+ let first = control_clock_ms_for_frame(500, 1, 101);
+ let repeated = control_clock_ms_for_frame(500, 1, 101);
+ assert_eq!(replay_environment_delta_seconds(initial, first), 0.005);
+ assert_eq!(replay_environment_delta_seconds(first, repeated), 0.0);
+ let next = control_clock_ms_for_frame(500, 2, 101);
+ assert_eq!(replay_environment_delta_seconds(first, next), 0.005);
+ assert_eq!(
+ replay_environment_delta_seconds(0, control_clock_ms_for_frame(500, 0, 1)),
+ 0.5
+ );
+ assert_eq!(replay_environment_delta_seconds(next, 0), 0.0);
+ }
+
+ #[test]
+ fn ambient_effect_screen_bounds_follow_vulkan_viewport_y_direction() {
+ let vertices = [
+ [-0.5, 0.25, 0.5],
+ [-0.5, 0.75, 0.5],
+ [0.5, 0.75, 0.5],
+ [0.5, 0.25, 0.5],
+ ];
+ let bounds = ambient_effect_projected_bounds(
+ &vertices,
+ [
+ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
+ ],
+ [100, 100],
+ )
+ .expect("visible clip-space quad");
+ assert_eq!(bounds, [25.0, 62.5, 75.0, 87.5]);
+ }
+
+ #[test]
+ fn native_tl_sprite_pixels_map_to_current_vulkan_viewport_at_z_999() {
+ let uv = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]];
+ let small = screen_tl_quad_vertices_pixels(
+ [160.0, 120.0],
+ 40.0,
+ 30.0,
+ [640.0, 480.0],
+ [1.0; 3],
+ 1.0,
+ uv,
+ );
+ let resized = screen_tl_quad_vertices_pixels(
+ [320.0, 240.0],
+ 80.0,
+ 60.0,
+ [1280.0, 960.0],
+ [1.0; 3],
+ 1.0,
+ uv,
+ );
+
+ for (small, resized) in small.iter().zip(resized) {
+ for axis in 0..2 {
+ assert!((small.position[axis] - resized.position[axis]).abs() < 1.0e-6);
+ }
+ assert_eq!(small.position[2], 0.999);
+ assert_eq!(resized.position[2], 0.999);
+ }
+ for (actual, expected) in [
+ (small[0].position, [-0.625, -0.375, 0.999]),
+ (small[2].position, [-0.625, -0.625, 0.999]),
+ ] {
+ for axis in 0..3 {
+ assert!((actual[axis] - expected[axis]).abs() < 1.0e-6);
+ }
}
+ assert_eq!(small[0].uv, [0.0, 1.0]);
+ assert_eq!(small[2].uv, [0.0, 0.0]);
+ }
+ #[test]
+ fn parses_control_clock_capture() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
@@ -9645,8 +14310,18 @@ mod tests {
"--control-clock-ms",
"500",
]))
- .expect("valid bounded replay arguments");
+ .expect("controlled clock capture arguments");
assert_eq!(parsed.control_clock_ms, Some(500));
+ assert!(Args::parse(&strings(&[
+ "--root",
+ "testdata/IS",
+ "--mission",
+ "MISSIONS/Autodemo.00/data.tma",
+ "--control-clock-ms",
+ "500",
+ ]))
+ .expect_err("replay needs multiple frames")
+ .contains("at least 2"));
}
#[test]
@@ -9661,9 +14336,12 @@ mod tests {
};
let mut bindings = vec![MaterialAnimationBinding {
initial_phase_index: 0,
+ normalized_phase_override: None,
+ ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices: vec![10, 11],
range_indices: Vec::new(),
+ pipeline_category: 0,
}],
base: MaterialPhaseBinding {
document: empty_mat0,
@@ -9684,6 +14362,7 @@ mod tests {
bindings[binding_index].variants.push(MaterialVariantSet {
phase_material_indices: vec![20, 21],
range_indices: vec![7],
+ pipeline_category: 0,
});
let lightmap_variant = &bindings[binding_index].variants[1];
@@ -9940,7 +14619,7 @@ mod tests {
}
#[test]
- fn captured_native_camera_uses_view_dependent_sky_fog_color() {
+ fn captured_native_camera_selects_view_dependent_screen_horizon_color() {
use fparkan_fx::atmosphere::{PackedColor, SkySample, SKY_COLOR_COUNT};
// Compact camera fields from the native 1493.337-second AutoDemo capture.
@@ -9962,12 +14641,12 @@ mod tests {
values: [0.0; 2],
packed: [PackedColor(0); 2],
};
- let fog_color =
+ let horizon_color =
fparkan_fx::sky::screen_gradient_frame(&sample, camera.native_sky_heading(), [0.0; 3])
.color;
assert_eq!(capture.atmosphere_seconds, Some(1493.337));
- assert_eq!(fog_color, PackedColor(0xFF3E_3E64));
+ assert_eq!(horizon_color, PackedColor(0xFF3E_3E64));
}
#[test]
@@ -9975,6 +14654,322 @@ mod tests {
assert_eq!(sky_normal([-128, 127, -64]), [-1.0, 127.0 / 128.0, -0.5]);
}
+ fn set_test_terrain_lod_slots(
+ terrain: &mut fparkan_terrain_format::LandMeshDocument,
+ node_slots: &[[u16; 2]],
+ face_ranges: &[[u16; 2]],
+ ) {
+ const NODE_STRIDE: usize = 38;
+ let mut nodes = vec![u8::MAX; node_slots.len() * NODE_STRIDE];
+ for (node_index, [lod0_slot, lod1_slot]) in node_slots.iter().copied().enumerate() {
+ let offset = node_index * NODE_STRIDE + 8;
+ nodes[offset..offset + 2].copy_from_slice(&lod0_slot.to_le_bytes());
+ nodes[offset + 2..offset + 4].copy_from_slice(&lod1_slot.to_le_bytes());
+ }
+ let mut slots = vec![[0_u8; 68]; face_ranges.len()];
+ for (slot, [start, count]) in slots.iter_mut().zip(face_ranges.iter().copied()) {
+ slot[..2].copy_from_slice(&start.to_le_bytes());
+ slot[2..4].copy_from_slice(&count.to_le_bytes());
+ }
+ terrain.nodes_raw = nodes;
+ terrain.slots.slots_raw = slots;
+ }
+
+ fn mission_root_for_basement(position: [f32; 3], raw_after_name: u32) -> MissionObjectDraft {
+ MissionObjectDraft {
+ original_id: None,
+ resource_name_raw: b"BUILDS/example.dat".to_vec(),
+ raw_after_resource: 0,
+ identity_or_clan_raw: 0,
+ raw_after_name,
+ unit_dat_header_flags: Some(0x8000_0000),
+ position,
+ orientation_raw: [0.0; 3],
+ scale: [1.0; 3],
+ visual_ids: Vec::new(),
+ properties: Vec::new(),
+ unit_components: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn basement_delta_updates_one_common_root_for_mesh_and_attached_component() {
+ let roots = [
+ mission_root_for_basement([1.0, 2.0, 100.0], 0),
+ mission_root_for_basement([3.0, 4.0, 200.0], 1),
+ ];
+ let footprints = [
+ MissionBasementFootprint {
+ object_index: 0,
+ outer: Vec::new(),
+ inner: Vec::new(),
+ flatten_inner_to_mean_outer_z: true,
+ },
+ MissionBasementFootprint {
+ object_index: 1,
+ outer: Vec::new(),
+ inner: Vec::new(),
+ flatten_inner_to_mean_outer_z: false,
+ },
+ ];
+ let placements = [
+ BasementPlacement {
+ placement_delta: 12.5,
+ },
+ BasementPlacement {
+ placement_delta: 25.0,
+ },
+ ];
+ let adjusted = apply_basement_placement_deltas(&roots, &footprints, &placements)
+ .expect("aligned native placements");
+ assert_eq!(adjusted[0].position, [1.0, 2.0, 87.5]);
+ assert_eq!(adjusted[1].position, roots[1].position);
+
+ let transform = LegacyIron3dEulerTransform {
+ translation: adjusted[0].position,
+ orientation_radians: adjusted[0].orientation_raw,
+ };
+ assert_eq!(
+ transform.try_transform_scaled_point([0.0, 0.0, 0.0], adjusted[0].scale),
+ Some([1.0, 2.0, 87.5])
+ );
+ assert_eq!(
+ transform.try_transform_scaled_point([0.0, 0.0, 4.0], adjusted[0].scale),
+ Some([1.0, 2.0, 91.5]),
+ "the attached component inherits the root shift once"
+ );
+ }
+
+ #[test]
+ fn basement_projection_shadows_and_queries_share_generated_base_only_faces() {
+ let mut land = fparkan_terrain_format::LandMeshDocument {
+ streams: Vec::new(),
+ nodes_raw: Vec::new(),
+ slots: fparkan_terrain_format::TerrainSlotTable {
+ header_raw: Vec::new(),
+ slots_raw: Vec::new(),
+ },
+ positions: vec![[0.0, 0.0, 5.0], [20.0, 0.0, 5.0], [0.0, 20.0, 5.0]],
+ normals: vec![[127, 0, 0, 0], [0, 127, 0, 0], [0, 0, 127, 0]],
+ uv0: vec![[256, 128], [1024, 256], [512, 1536]],
+ accelerator: Vec::new(),
+ aux14: Vec::new(),
+ aux18: Vec::new(),
+ faces: vec![fparkan_terrain_format::TerrainFace28 {
+ flags: FullSurfaceMask(0x400),
+ material_tag: 0xff00,
+ aux_tag: 0,
+ vertices: [0, 1, 2],
+ neighbors: [None, None, None],
+ tail_raw: [0; 8],
+ raw: [0; 28],
+ }],
+ };
+ set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
+ let source_mesh = project_land_msh_to_static_mesh_in_world_space(&land)
+ .expect("project one LOD0 terrain face");
+ let source_world = TerrainWorld::from_land_msh(&land).expect("source terrain");
+ let (empty_mesh, empty_shadows, empty_query) =
+ prepare_static_terrain_geometry(&land, source_mesh.clone(), &source_world, None)
+ .expect("keep unmodified terrain when there are no foundations");
+ assert_eq!(empty_mesh, source_mesh);
+ assert_eq!(empty_query.surface_count(), source_world.surface_count());
+ assert_eq!(empty_shadows.len(), 1);
+ assert!(empty_mesh
+ .vertices
+ .iter()
+ .any(|vertex| vertex.normal != [0.0; 3]));
+ assert!(empty_mesh
+ .vertices
+ .iter()
+ .any(|vertex| vertex.uv != [0.0; 2]));
+ let band_positions = [
+ [-18.25, -18.25, 5.0],
+ [-2.25, -18.25, 5.0],
+ [-18.25, -2.25, 5.0],
+ ];
+ let mut foundation = BasementFoundation {
+ fragments: vec![
+ BasementFoundationFragment {
+ source_face: Some(0),
+ surface_mask: FullSurfaceMask(0x400),
+ positions: [[0.0, 0.0, 5.0], [20.0, 0.0, 5.0], [0.0, 20.0, 5.0]],
+ },
+ BasementFoundationFragment {
+ source_face: None,
+ surface_mask: FullSurfaceMask(0x8300),
+ positions: band_positions,
+ },
+ ],
+ placements: Vec::new(),
+ };
+ let (render_mesh, shadow_receivers) =
+ project_basement_foundation(&land, &source_mesh, &mut foundation)
+ .expect("project canonical basement faces");
+ let expected = foundation.fragments.clone();
+ assert_eq!(render_mesh.draw_ranges.len(), 2);
+ assert!(render_mesh
+ .draw_ranges
+ .iter()
+ .all(|range| range.material_index == 0xff00));
+ assert!(!render_mesh.draw_ranges[0].terrain_base_only);
+ assert!(render_mesh.draw_ranges[1].terrain_base_only);
+ assert_eq!(render_mesh.draw_ranges[1].first_index, 3);
+ assert_eq!(shadow_receivers.len(), expected.len());
+
+ for (triangle_index, fragment) in expected.iter().enumerate() {
+ let indices: [u32; 3] = render_mesh.indices[triangle_index * 3..triangle_index * 3 + 3]
+ .try_into()
+ .expect("projected foundation triangle");
+ let positions = indices.map(|index| render_mesh.vertices[index as usize].position);
+ assert_eq!(positions, fragment.positions);
+ assert_eq!(
+ shadow_receivers[triangle_index].positions,
+ fragment.positions
+ );
+ assert_eq!(
+ shadow_receivers[triangle_index].mask,
+ fragment.surface_mask.0
+ );
+ }
+ assert_eq!(
+ basement_band_uv_vertices(band_positions)
+ .expect("native per-face UVs")
+ .map(|vertex| vertex.uv0),
+ [[815, 815], [1896, 815], [815, 1896]],
+ "negative coordinates use one native floor origin per triangle"
+ );
+ assert!(render_mesh.vertices[3..6]
+ .iter()
+ .flat_map(|vertex| vertex.uv)
+ .all(|value| (value * 1024.0).fract() == 0.0));
+
+ let query_world = source_world
+ .with_basement_foundation(foundation)
+ .expect("install canonical geometry");
+ assert_eq!(query_world.surface_count(), expected.len());
+ assert_eq!(
+ query_world
+ .basement_foundation()
+ .expect("installed foundation")
+ .fragments,
+ expected
+ );
+ assert_eq!(query_world.height_at([-14.0, -14.0]).unwrap(), Some(5.0));
+ }
+
+ #[test]
+ fn basement_band_normals_sum_packed_faces_for_native_welded_vertices() {
+ let mut foundation = BasementFoundation {
+ fragments: vec![
+ BasementFoundationFragment {
+ source_face: None,
+ surface_mask: FullSurfaceMask(0x8300),
+ positions: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
+ },
+ BasementFoundationFragment {
+ source_face: None,
+ surface_mask: FullSurfaceMask(0x8300),
+ positions: [[0.005, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 1.0]],
+ },
+ ],
+ placements: Vec::new(),
+ };
+ let (normals, uv0_words) = canonicalize_basement_band_vertices(&mut foundation)
+ .expect("native packed normal pass");
+ let first = normals[0].expect("first synthetic face");
+ let second = normals[1].expect("second synthetic face");
+ assert_eq!(first[0], second[0], "sub-centimeter XY vertices are welded");
+ assert_eq!(foundation.fragments[1].positions[0], [0.0, 0.0, 0.0]);
+ assert_eq!(
+ first[2], second[1],
+ "shared vertices use both packed face normals"
+ );
+ assert_eq!(uv0_words[1].expect("second face UV words")[0], [0, 0]);
+ assert_eq!(first[0][1], 0.0);
+ assert_ne!(first[0], [0.0, 0.0, 127.0 / 128.0]);
+ }
+
+ #[test]
+ fn basement_face_normal_distinguishes_degenerate_from_non_finite() {
+ assert_eq!(
+ packed_basement_face_normal([[0.0; 3], [1.0; 3], [2.0; 3]])
+ .expect("zero normal is a native zero contribution"),
+ None
+ );
+ assert!(packed_basement_face_normal([
+ [f32::NAN, 0.0, 0.0],
+ [1.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0]
+ ])
+ .is_err());
+ }
+
+ #[test]
+ fn c00_m02_world_terrain_edge_rounding_keeps_overlay_alpha_in_range() {
+ // DATA/MAPS/Tut_2/Land.msh face 2525: this split point lands about
+ // 1.35e-6 outside one source edge after the contour kernel's f32
+ // world-coordinate output is converted back to f64 barycentrics.
+ let source = [
+ VulkanStaticVertex {
+ position: [815.20465, 281.67642, 320.0],
+ color: [1.0; 3],
+ normal: [0.0, 0.0, 1.0],
+ uv: [0.0; 2],
+ detail_uv: [0.0; 2],
+ overlay_alpha: 1.0,
+ },
+ VulkanStaticVertex {
+ position: [775.4386, 308.18713, 360.00003],
+ color: [1.0; 3],
+ normal: [0.0, 0.0, 1.0],
+ uv: [0.0; 2],
+ detail_uv: [0.0; 2],
+ overlay_alpha: 1.0,
+ },
+ VulkanStaticVertex {
+ position: [775.4386, 288.3041, 361.6667],
+ color: [1.0; 3],
+ normal: [0.0, 0.0, 1.0],
+ uv: [0.0; 2],
+ detail_uv: [0.0; 2],
+ overlay_alpha: 1.0,
+ },
+ ];
+ let split_position = [800.6648, 291.3697, 334.62543];
+ let point = [f64::from(split_position[0]), f64::from(split_position[1])];
+ let a = [
+ f64::from(source[0].position[0]),
+ f64::from(source[0].position[1]),
+ ];
+ let b = [
+ f64::from(source[1].position[0]),
+ f64::from(source[1].position[1]),
+ ];
+ let c = [
+ f64::from(source[2].position[0]),
+ f64::from(source[2].position[1]),
+ ];
+ let denominator = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
+ let second_weight =
+ ((point[0] - a[0]) * (c[1] - a[1]) - (point[1] - a[1]) * (c[0] - a[0])) / denominator;
+ let third_weight =
+ ((b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (point[0] - a[0])) / denominator;
+ let raw_overlay_alpha = [
+ 1.0 - second_weight - third_weight,
+ second_weight,
+ third_weight,
+ ]
+ .map(|weight| weight as f32)
+ .into_iter()
+ .sum::<f32>();
+ assert!(raw_overlay_alpha > 1.0 && raw_overlay_alpha <= 1.0 + 1.0e-5);
+ let vertex = interpolate_static_terrain_vertex(split_position, source)
+ .expect("bounded alpha rounding at a contour split");
+ assert_eq!(vertex.overlay_alpha, 1.0);
+ assert!((0.0..=1.0).contains(&vertex.overlay_alpha));
+ }
+
#[test]
fn sun_visibility_uses_a_valid_ray_and_fails_open_without_surfaces() {
let camera = FreeFlightCamera {
@@ -10016,14 +15011,9 @@ mod tests {
raw: [0; 28],
}],
};
- set_test_terrain_lod0_range(&mut land);
+ set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
let shadow_receivers = terrain_shadow_receivers(&land).expect("raw-height receivers");
assert_eq!(shadow_receivers[0].positions[0], [0.0, 0.0, 32.0]);
- let mut invalid_shadow_face = land.clone();
- invalid_shadow_face.faces[0].vertices[2] = u16::MAX;
- assert!(terrain_shadow_receivers(&invalid_shadow_face)
- .expect_err("bad terrain indices must fail instead of inventing vertices")
- .contains("unknown vertex 65535"));
let terrain = TerrainWorld::from_land_msh(&land).expect("synthetic terrain");
assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(32.0));
let floor_camera =
@@ -10099,7 +15089,7 @@ mod tests {
raw: [0; 28],
}],
};
- set_test_terrain_lod0_range(&mut excluded_land);
+ set_test_terrain_lod_slots(&mut excluded_land, &[[0, u16::MAX]], &[[0, 1]]);
let excluded_terrain =
TerrainWorld::from_land_msh(&excluded_land).expect("excluded terrain");
assert!(native_sun_unoccluded_with_world(
@@ -10146,7 +15136,7 @@ mod tests {
raw: [0; 28],
}],
};
- set_test_terrain_lod0_range(&mut land);
+ set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
let terrain = TerrainWorld::from_land_msh(&land).expect("sloped terrain");
assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(33.5));
assert_eq!(terrain.height_at([0.75, 0.25]).unwrap(), Some(34.5));
@@ -10361,6 +15351,71 @@ mod tests {
}
#[test]
+ fn controlled_readback_sequence_requires_and_parses_replay_clock() {
+ let parsed = Args::parse(&strings(&[
+ "--root",
+ "testdata/IS",
+ "--mission",
+ "MISSIONS/Autodemo.00/data.tma",
+ "--frames",
+ "11",
+ "--control-clock-ms",
+ "0",
+ "--readback-sequence-out",
+ "target/frames",
+ ]))
+ .expect("valid fixed-clock readback sequence");
+ assert_eq!(
+ parsed.readback_sequence_out,
+ Some(PathBuf::from("target/frames"))
+ );
+ assert_eq!(parsed.control_clock_ms, Some(0));
+
+ let error = Args::parse(&strings(&[
+ "--root",
+ "testdata/IS",
+ "--mission",
+ "MISSIONS/Autodemo.00/data.tma",
+ "--frames",
+ "11",
+ "--readback-sequence-out",
+ "target/frames",
+ ]))
+ .expect_err("sequence captures need deterministic clock input");
+ assert!(error.contains("requires --control-clock-ms"));
+ }
+
+ #[test]
+ fn readback_sequence_writes_clock_named_frame_without_overwrite() {
+ let nonce = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock after epoch")
+ .as_nanos();
+ let directory = std::env::temp_dir().join(format!(
+ "fparkan-readback-sequence-{}-{nonce}",
+ std::process::id()
+ ));
+ let artifact = VulkanReadbackArtifact {
+ format: 37,
+ width: 2,
+ height: 1,
+ bytes: vec![1, 2, 3, 4, 5, 6, 7, 8],
+ };
+ let path = write_readback_sequence_frame(&directory, 3, 50, &artifact)
+ .expect("write frame artifact");
+ assert_eq!(
+ path.file_name().and_then(|name| name.to_str()),
+ Some("frame-000003-clock-0000000050ms-2x1.rgba")
+ );
+ assert_eq!(
+ std::fs::read(&path).expect("read frame artifact"),
+ artifact.bytes
+ );
+ assert!(write_readback_sequence_frame(&directory, 3, 50, &artifact).is_err());
+ std::fs::remove_dir_all(directory).expect("remove frame artifact directory");
+ }
+
+ #[test]
fn parses_static_animation_frame() {
let parsed = Args::parse(&strings(&[
"--root",
@@ -10536,6 +15591,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");
@@ -10598,6 +15706,22 @@ mod tests {
}
#[test]
+ fn captured_sky_uv_phase_requires_verified_clock() {
+ let mut value: serde_json::Value =
+ serde_json::from_slice(include_bytes!("../testdata/legacy-camera-parented.json"))
+ .unwrap();
+ value["sky_uv_clock"] =
+ serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32});
+ let capture = parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap();
+ assert_eq!(
+ capture.sky_uv_clock.unwrap().elapsed_milliseconds,
+ 4051271258
+ );
+ value["sky_uv_clock"]["verified"] = serde_json::json!(false);
+ assert!(parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).is_err());
+ }
+
+ #[test]
fn captured_atmosphere_is_default_and_cli_time_takes_precedence() {
let capture = LegacyCameraCapture {
schema: "fparkan-legacy-camera-v1".to_string(),
@@ -10611,6 +15735,8 @@ mod tests {
far_plane: 1.0,
field_of_view_radians: 1.0,
atmosphere_seconds: Some(56.227),
+ geometry_projection: None,
+ sky_uv_clock: None,
};
assert_eq!(
@@ -10861,328 +15987,1067 @@ 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 = ControlEffectSlot {
+ time_mode: 1,
+ header_flags: 0,
+ random_instance_key: ControlEffectInstanceKey {
+ object_index: 0,
+ component_index: 0,
+ table_index: 0,
+ row_index: 0,
+ },
+ random_seed: 1,
+ phase_jitter: 0.0,
+ random_shift: [0.0; 3],
+ duration_seconds: 1.0,
+ owner_object_index: 0,
+ owner_component_index: 0,
+ owner_node_index: 0,
+ owner_component_is_mounted: false,
+ root_transform: LegacyIron3dEulerTransform {
+ translation: [10.0, 20.0, 30.0],
+ orientation_radians: [0.0; 3],
+ },
+ owner_scale: [1.0; 3],
+ mount_pose: None,
+ local_from_effect: [
+ [1.0, 0.0, 0.0, 1.0],
+ [0.0, 1.0, 0.0, 2.0],
+ [0.0, 0.0, 1.0, 3.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ],
+ world_from_effect: [[0.0; 4]; 4],
+ command_index: 0,
+ command: endpoint_op1_command(6, 0.5, 1.0),
+ };
+ let poses = vec![vec![None]];
+ let mounts = vec![vec![None]];
+
+ let inactive = slot.clone();
+ let random_samples = HashMap::new();
+ assert!(update_control_point_lights(
+ &mut [inactive],
+ &poses,
+ &mounts,
+ 0.25,
+ &random_samples,
+ false,
+ )
+ .is_empty());
+ let lights = update_control_point_lights(
+ std::slice::from_mut(&mut slot),
+ &poses,
+ &mounts,
+ 0.75,
+ &random_samples,
+ false,
+ );
+ assert_eq!(lights.len(), 1);
+ let (light, flags) = lights[0];
+ assert_eq!(light.position, [13.0, 22.0, 33.0]);
+ assert_eq!(light.rgb, [0.25, 0.5, 0.75]);
+ assert_eq!(light.range, 15.0);
+ assert_eq!(light.coefficients, [1.0, 0.5, 0.25]);
+ assert_eq!(flags, 0xA000_0000);
assert_eq!(
- native_msh_cull_mode(range.batch_flags, 1),
- LegacyCullMode::Disabled
+ [
+ slot.world_from_effect[0][3],
+ slot.world_from_effect[1][3],
+ slot.world_from_effect[2][3]
+ ],
+ [11.0, 22.0, 33.0]
+ );
+
+ slot.header_flags = 0x29;
+ slot.random_seed = 0x1234_5678;
+ slot.phase_jitter = 0.4;
+ slot.random_shift = [2.0, 4.0, 6.0];
+ let sibling_slots = [slot.clone(), slot.clone()];
+ let mut states = HashMap::new();
+ let first = advance_control_effect_random_states(
+ &mut states,
+ &[],
+ &sibling_slots,
+ &[],
+ SceneSampleId::Replay(7),
+ 150,
+ 1.5,
+ );
+ assert_eq!(first.len(), 1);
+ let key = slot.random_instance_key;
+ let first_sample = first[&key];
+ assert_eq!(first_sample.phase_jitter, 0.0, "the -1 phase skips jitter");
+ assert_ne!(first_sample.accumulated_shift, [0.0; 3]);
+ let mut expected_state = ControlEffectRandomState::new(
+ slot.random_seed,
+ slot.header_flags,
+ slot.phase_jitter,
+ slot.random_shift,
+ );
+ assert_eq!(
+ first_sample,
+ expected_state.advance(SceneSampleId::Replay(7), 150, -1.0)
+ );
+ assert_eq!(
+ advance_control_effect_random_states(
+ &mut states,
+ &[],
+ &sibling_slots,
+ &[],
+ SceneSampleId::Replay(7),
+ 150,
+ 1.5,
+ )[&key],
+ first_sample,
+ "a repeated read of the same captured update reuses the shared sample"
+ );
+ let second = advance_control_effect_random_states(
+ &mut states,
+ &[],
+ &sibling_slots,
+ &[],
+ SceneSampleId::Replay(8),
+ 200,
+ 0.5,
+ );
+ assert_eq!(
+ second[&key],
+ expected_state.advance(SceneSampleId::Replay(8), 200, 1.0)
+ );
+ assert_ne!(second[&key].phase_jitter, first_sample.phase_jitter);
+ assert_ne!(
+ second[&key].accumulated_shift,
+ first_sample.accumulated_shift
+ );
+ assert_eq!(states[&key].last_update_clock_ms, Some(200));
+
+ let negative_non_sentinel =
+ ambient_effect_phase(1, 0x220, 1.5, 1.0).expect("post-duration transformed phase");
+ assert_eq!(negative_non_sentinel, -2.5);
+ let mut negative_state = ControlEffectRandomState::new(
+ slot.random_seed,
+ 0x229,
+ slot.phase_jitter,
+ slot.random_shift,
+ );
+ assert_eq!(
+ native_command_phase(0x229, negative_non_sentinel, 0.4),
+ 0.0,
+ "a phase below -1 clamps to zero rather than using the -1 sentinel"
+ );
+ assert_ne!(
+ negative_state
+ .advance(SceneSampleId::Live(1), 150, negative_non_sentinel)
+ .phase_jitter,
+ 0.0,
+ "a non-sentinel negative phase still consumes the jitter sample"
);
}
#[test]
- fn world_draw_range_count_restores_after_alpha_and_msh_facing_changes() {
- let triangle = native_front_triangle();
- let (mesh, range) = facing_mesh(0x4000, &triangle);
- let original_count = range.index_count;
+ fn op1_generic_diffuse_list_excludes_bit31_and_clears_inactive_lights() {
+ let light = |position, active| VulkanPointLight {
+ position,
+ rgb: [0.25, 0.5, 0.75],
+ range: 15.0,
+ coefficients: [1.0, 0.5, 0.25],
+ active,
+ };
+ let raw_records = vec![
+ (light([1.0, 2.0, 3.0], true), 0xA000_0000),
+ (light([4.0, 5.0, 6.0], true), 0x2000_0000),
+ (light([7.0, 8.0, 9.0], true), 0x8000_0000),
+ (light([10.0, 11.0, 12.0], false), 0x0000_0000),
+ ];
+ let ordinary = ordinary_control_point_lights(&raw_records);
+ assert_eq!(ordinary.len(), 1);
+ assert_eq!(ordinary[0].position, [4.0, 5.0, 6.0]);
+ assert_eq!(raw_records.len(), 4);
assert_eq!(
- world_draw_range_index_count(&mesh, &range, original_count, 0.0, [0.0; 3], Some(1)),
- 0
+ raw_records
+ .iter()
+ .map(|(_, flags)| *flags)
+ .collect::<Vec<_>>(),
+ vec![0xA000_0000, 0x2000_0000, 0x8000_0000, 0]
+ );
+
+ assert!(ordinary_control_point_lights(&[]).is_empty());
+ assert!(
+ ordinary_control_point_lights(&[(light([13.0, 14.0, 15.0], false), 0x2000_0000)])
+ .is_empty()
);
+ }
+
+ #[test]
+ fn op1_control_slot_accepts_supported_bodies_with_instance_random_flags() {
+ let command = endpoint_op1_command(6, 0.0, 1.0);
+ assert!(ambient_effect_op1_supported(&command));
+ assert!(!ambient_effect_op1_supported(&endpoint_op1_command(
+ 4, 0.0, 1.0
+ )));
+ }
+
+ #[test]
+ fn op1_phase_preserves_native_sentinel_and_initial_manager_gate() {
+ assert_eq!(native_command_phase(0, -1.0, 0.4), -1.0);
+ assert_eq!(native_command_phase(0x100, 0.625, 0.0), 0.625);
+ assert_eq!(native_command_phase(0x80, 0.625, 0.4), 0.0);
+ assert_eq!(native_command_phase(0x81, -1.0, 0.4), 0.4);
+ assert_eq!(native_command_phase(0, -0.25, 0.0), 0.0);
+ assert_eq!(native_command_phase(0, 1.25, 0.0), 1.0);
+ assert_eq!(native_command_phase(1, 0.5, 0.25), 0.75);
+ assert_eq!(native_command_phase(1, 0.9, 0.25), 1.0);
+ let flags_200_20 =
+ ambient_effect_phase(1, 0x220, 1.5, 1.0).expect("triangular post-duration phase");
+ assert_eq!(flags_200_20, -2.5);
+ assert_eq!(native_command_phase(0x220, flags_200_20, 0.4), 0.0);
+ let raw_minus_one =
+ ambient_effect_phase(1, 0x20, 1.5, 1.0).expect("triangular sentinel phase");
+ assert_eq!(raw_minus_one, -1.0);
+ assert_eq!(native_command_phase(0x21, raw_minus_one, 0.4), -1.0);
+ assert_eq!(native_command_phase(0xA1, raw_minus_one, 0.4), 0.4);
+ }
+
+ #[test]
+ fn op7_variable_frames_clear_inactive_draw_ranges() {
+ let aligned = align_ambient_effect_frames(
+ vec![endpoint_effect_frame(3), endpoint_effect_frame(7)],
+ 4,
+ true,
+ )
+ .expect("variable Op7 output fits its fixed range allocation");
+ assert_eq!(aligned.len(), 4);
assert_eq!(
- world_draw_range_index_count(
- &mesh,
- &range,
- original_count,
- 1.0,
- [0.0, 0.0, -2.0],
- Some(1),
- ),
- 0
+ aligned[0].as_ref().map(|frame| frame.command_index),
+ Some(3)
);
assert_eq!(
- world_draw_range_index_count(&mesh, &range, original_count, 1.0, [0.0; 3], Some(1)),
- original_count
+ aligned[1].as_ref().map(|frame| frame.command_index),
+ Some(7)
);
+ assert!(aligned[2].is_none() && aligned[3].is_none());
+
+ assert!(align_ambient_effect_frames(vec![endpoint_effect_frame(0)], 4, false,).is_err());
+ assert!(align_ambient_effect_frames(vec![endpoint_effect_frame(0); 5], 4, true,).is_err());
+ }
+
+ #[test]
+ fn active_control_fx_vertex_color_uses_sampled_mat0_additive_rgb() {
+ let mut slot = AmbientEffectGpuSlot {
+ kind: AmbientEffectKind::Op3Type2,
+ time_mode: 1,
+ header_flags: 0,
+ random_instance_key: ControlEffectInstanceKey {
+ object_index: 0,
+ component_index: 0,
+ table_index: 0,
+ row_index: 0,
+ },
+ random_seed: 1,
+ phase_jitter: 0.0,
+ random_shift: [0.0; 3],
+ owner_object_index: 0,
+ owner_component_index: 0,
+ owner_node_index: 0,
+ owner_component_is_mounted: false,
+ root_transform: LegacyIron3dEulerTransform {
+ translation: [0.0; 3],
+ orientation_radians: [0.0; 3],
+ },
+ owner_scale: [1.0; 3],
+ mount_pose: None,
+ local_from_effect: [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ],
+ control_source: String::new(),
+ control_table_index: 0,
+ control_row_index: 0,
+ control_point_indices: [0; 3],
+ control_point_owners: [0; 3],
+ command: endpoint_op3_command(),
+ command_index: 0,
+ duration_seconds: 1.0,
+ world_from_effect: [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ],
+ range: EnvironmentGpuRange {
+ range_index: 0,
+ vertex_start: 0,
+ vertex_capacity: 4,
+ material_index: 0,
+ },
+ additional_particle_ranges: vec![EnvironmentGpuRange {
+ range_index: 1,
+ vertex_start: 4,
+ vertex_capacity: 4,
+ material_index: 1,
+ }],
+ material_animation_index: 0,
+ additional_particle_material_animation_indices: vec![1],
+ smoke_emitter_state: None,
+ op7_emitter_state: None,
+ };
assert_eq!(
- world_draw_range_index_count(
- &mesh,
- &range,
- original_count,
- 1.0,
- [0.0, 0.0, -2.0],
- None,
- ),
- original_count
+ ambient_effect_ranges(&slot)
+ .iter()
+ .map(|(range, binding)| (range.range_index, *binding))
+ .collect::<Vec<_>>(),
+ vec![(0, 0), (1, 1)]
);
+ let mut mesh = VulkanStaticMesh {
+ vertices: vec![environment_vertex([0.0; 3], [1.0; 3], [0.0; 2]); 8],
+ indices: Vec::new(),
+ draw_ranges: Vec::new(),
+ };
+ let updates = [
+ AmbientEffectGeometryUpdate {
+ range_index: 0,
+ index_count: 6,
+ command_rgb: [1.0; 3],
+ },
+ AmbientEffectGeometryUpdate {
+ range_index: 1,
+ index_count: 6,
+ command_rgb: [1.0; 3],
+ },
+ ];
+ let mut active_materials = HashMap::from([
+ (
+ 0,
+ ActiveMaterialState {
+ material_index: 0,
+ phase_index: 0,
+ diffuse_alpha: 1.0,
+ additive_rgb: [0.9882353, 1.0, 0.16862746],
+ },
+ ),
+ (
+ 1,
+ ActiveMaterialState {
+ material_index: 1,
+ phase_index: 0,
+ diffuse_alpha: 1.0,
+ additive_rgb: [0.9882353, 1.0, 0.16862746],
+ },
+ ),
+ ]);
+
+ apply_ambient_effect_material_tints(
+ &[slot.clone()],
+ &mut mesh,
+ &updates,
+ &active_materials,
+ )
+ .expect("apply sampled yellow MAT0 color");
+ assert!(mesh
+ .vertices
+ .iter()
+ .all(|vertex| vertex.color == [0.9882353, 1.0, 0.16862746]));
+
+ for material in active_materials.values_mut() {
+ material.additive_rgb = [0.1, 0.7, 0.2];
+ }
+ slot.kind = AmbientEffectKind::SmokePool;
+ apply_ambient_effect_material_tints(&[slot], &mut mesh, &updates, &active_materials)
+ .expect("apply next sampled MAT0 color");
+ assert!(mesh
+ .vertices
+ .iter()
+ .all(|vertex| vertex.color == [0.1, 0.7, 0.2]));
}
+
#[test]
- fn world_draw_range_count_clears_and_reapplies_constant_alpha_override() {
- let triangle = native_front_triangle();
- let (mesh, mut range) = facing_mesh(0x0148, &triangle);
- let original_count = range.index_count;
- let mut alpha_override = None;
- let update = |range: &VulkanStaticDrawRange, alpha_override: &mut Option<f32>| {
- *alpha_override = native_msh_diffuse_alpha_override(range.batch_flags, false);
- world_draw_range_index_count(
- &mesh,
- range,
- original_count,
- alpha_override.unwrap_or(1.0),
- [0.0; 3],
- None,
- )
+ fn ambient_fx_uses_vertex_opacity_without_multiplying_mat0_opacity() {
+ let mat0_opacity = ambient_effect_material_alpha(true, 0.4);
+ let effect_opacity = 0.5;
+ let texture_alpha = 0.25;
+ assert_eq!(mat0_opacity, 1.0);
+ assert_eq!(mat0_opacity * effect_opacity, 0.5);
+ assert_eq!(texture_alpha * mat0_opacity * effect_opacity, 0.125);
+ assert_eq!(ambient_effect_material_alpha(false, 0.4), 0.4);
+ }
+
+ #[test]
+ fn material_animation_cpu_packet_uses_sampled_phase_without_renderer() {
+ let phase = |additive_red| MaterialPhase {
+ parameters: [
+ additive_red,
+ 0,
+ 0,
+ 100,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ ],
+ texture_raw: [0; 16],
+ };
+ let document = Mat0Document {
+ version: 0,
+ animation_block_count: 1,
+ phases: vec![phase(0), phase(255)],
+ prefix: Vec::new(),
+ header_opaque: [0; 2],
+ animation_blocks: vec![MaterialAnimationBlock {
+ header_raw: 0,
+ keys: vec![
+ MaterialKey {
+ k0: 0,
+ k1: 500,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 1,
+ k1: 1000,
+ k2: 0,
+ },
+ ],
+ bytes: Vec::new(),
+ }],
+ };
+ let mut binding = MaterialAnimationBinding {
+ initial_phase_index: 0,
+ normalized_phase_override: Some(0.75),
+ ambient_fx_material_alpha: true,
+ variants: Vec::new(),
+ base: MaterialPhaseBinding {
+ document,
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: 0,
+ frozen_phase_index: None,
+ phase_uv_transforms: vec![[0.0, 0.0, 1.0, 1.0]; 2],
+ },
+ detail: None,
+ overlay: None,
+ overlay_detail: None,
+ stage_textures: None,
};
- assert_eq!(update(&range, &mut alpha_override), 0);
- range.batch_flags = 0x0108;
- assert_eq!(update(&range, &mut alpha_override), original_count);
- range.batch_flags = 0x0148;
- assert_eq!(update(&range, &mut alpha_override), 0);
- assert_eq!(original_count, 3);
+ let effect_sample = sample_material_animation_base(&mut binding, 0)
+ .expect("sample the effect's native normalized MAT0 phase");
+ assert_eq!(effect_sample.phase_index, 1);
+ assert_eq!(effect_sample.coefficients.additive_rgb, [1.0, 0.0, 0.0]);
+
+ binding.normalized_phase_override = None;
+ let world_sample = sample_material_animation_base(&mut binding, 0)
+ .expect("sample ordinary clock-driven material phase");
+ assert_eq!(world_sample.phase_index, 0);
+ assert_eq!(world_sample.coefficients.additive_rgb, [0.0, 0.0, 0.0]);
+
+ binding.variants.push(MaterialVariantSet {
+ phase_material_indices: vec![0, 0],
+ range_indices: vec![7],
+ pipeline_category: 0,
+ });
+ let mut materials = Vec::new();
+ append_environment_gradient_material(&mut materials)
+ .expect("prepare test material without a renderer");
+ let update =
+ sample_material_animations(std::slice::from_mut(&mut binding), &materials, 500)
+ .expect("produce a CPU material-frame packet without a renderer");
+ assert_eq!(update.active_materials[&7].material_index, 0);
+ assert_eq!(update.active_materials[&7].phase_index, 1);
+ assert_eq!(update.range_selections[0].range_index, 7);
+ }
+
+ #[test]
+ fn ambient_fx_tint_requires_a_proven_ambient_mat0_phase() {
+ assert!(ambient_effect_tint_supported(
+ &[false, false],
+ &[[0.0; 3], [0.2; 3]],
+ &[[1.0, 1.0, 1.0], [0.2, 0.4, 0.8]],
+ ));
+ assert!(!ambient_effect_tint_supported(&[], &[], &[]));
+ assert!(!ambient_effect_tint_supported(&[false], &[], &[]));
+ assert!(!ambient_effect_tint_supported(
+ &[true],
+ &[[0.0; 3]],
+ &[[1.0; 3]]
+ ));
+ assert!(!ambient_effect_tint_supported(
+ &[false],
+ &[[0.1, 0.0, 0.0]],
+ &[[0.0; 3]],
+ ));
+ assert!(!ambient_effect_tint_supported(
+ &[false],
+ &[[0.0; 3]],
+ &[[f32::NAN, 1.0, 1.0]]
+ ));
+ }
+
+ #[test]
+ fn ambient_fx_ranges_are_excluded_from_world_alpha_and_count_updates() {
+ let fixed_ranges = HashSet::from([1, 4, 7]);
+ let world_ranges = world_draw_range_indices(9, &fixed_ranges);
+ assert_eq!(world_ranges, vec![0, 2, 3, 5, 6, 8]);
+ assert!(!world_ranges.contains(&7));
+ }
+
+ #[test]
+ fn ambient_control_rows_accept_unconditional_low_words_but_skip_state_gates_and_chains() {
+ let mut row = PreparedControlEffectRow {
+ table_index: 2,
+ row_index: 0,
+ raw_condition: [0, 7, 9],
+ point_offsets: [0, 1, 2],
+ selector: 0,
+ resource: fparkan_assets::PreparedControlResourcePair {
+ archive: [0; 32],
+ name: [0; 32],
+ },
+ };
+ assert!(ambient_effect_row_unconditionally_active(&row));
+ for condition_flag in [0x1000_0000, 0x2000_0000, 0x4000_0000, 0x8000_0000] {
+ row.raw_condition[0] = condition_flag;
+ assert!(!ambient_effect_row_unconditionally_active(&row));
+ }
}
}
diff --git a/apps/fparkan-game/src/terrain_lod.rs b/apps/fparkan-game/src/terrain_lod.rs
new file mode 100644
index 0000000..599178c
--- /dev/null
+++ b/apps/fparkan-game/src/terrain_lod.rs
@@ -0,0 +1,286 @@
+use fparkan_render_vulkan::{
+ project_land_msh_lod_to_static_mesh_in_world_space, VulkanStaticDrawRange, VulkanStaticMesh,
+};
+use fparkan_terrain::{BasementFoundation, TerrainLod, TerrainLodPlan};
+use fparkan_terrain_format::LandMeshDocument;
+
+pub(super) struct TerrainLodDraws {
+ plan: TerrainLodPlan,
+ ranges: Vec<(Option<usize>, TerrainLod)>,
+}
+
+impl TerrainLodDraws {
+ pub(super) fn index_count(
+ &self,
+ range: usize,
+ count: u32,
+ eye: [f32; 3],
+ fov: f32,
+ ) -> Result<u32, String> {
+ let Some(&(Some(node), lod)) = self.ranges.get(range) else {
+ return Ok(count);
+ };
+ let selected = self
+ .plan
+ .selected_lod(node, [eye[0], eye[1]], fov)
+ .ok_or_else(|| "invalid terrain LOD camera or cell".to_string())?;
+ Ok(if selected == lod { count } else { 0 })
+ }
+
+ pub(super) fn report(&self, eye: [f32; 3], fov: f32) -> Result<(), String> {
+ let mut counts = [0; 2];
+ for (range, &(_, lod)) in self.ranges.iter().enumerate() {
+ if self.index_count(range, 1, eye, fov)? != 0 {
+ counts[usize::from(lod == TerrainLod::Lod1)] += 1;
+ }
+ }
+ eprintln!(
+ "render_coverage terrain_lod_selected_ranges lod0={} lod1={}",
+ counts[0], counts[1]
+ );
+ Ok(())
+ }
+}
+
+pub(super) fn prepare(
+ terrain: &LandMeshDocument,
+ mut near_mesh: VulkanStaticMesh,
+ foundation: Option<&BasementFoundation>,
+) -> Result<(VulkanStaticMesh, Option<TerrainLodDraws>), String> {
+ let assignments = terrain.face_lod_assignments().map_err(|e| e.to_string())?;
+ if !assignments.iter().any(|a| a.lod == TerrainLod::Lod1) {
+ return Ok((near_mesh, None));
+ }
+ let plan = TerrainLodPlan::new(terrain, foundation)
+ .map_err(|e| format!("prepare terrain LOD: {e}"))?;
+ let near_nodes = if let Some(foundation) = foundation {
+ foundation
+ .fragments
+ .iter()
+ .map(|fragment| {
+ fragment
+ .source_face
+ .map(|face| {
+ plan.source_face_node(face)
+ .ok_or_else(|| "foundation LOD source face is missing".to_string())
+ })
+ .transpose()
+ })
+ .collect::<Result<Vec<_>, _>>()?
+ } else {
+ assignments
+ .iter()
+ .filter(|a| a.lod == TerrainLod::Lod0)
+ .map(|a| Some(a.node_index))
+ .collect()
+ };
+ let mut ranges = split_ranges(&mut near_mesh, &near_nodes, TerrainLod::Lod0)?;
+ if plan.has_far_geometry() {
+ let mut far_mesh =
+ project_land_msh_lod_to_static_mesh_in_world_space(terrain, TerrainLod::Lod1)
+ .map_err(|e| format!("project far terrain: {e}"))?;
+ let far_nodes = assignments
+ .iter()
+ .filter(|a| a.lod == TerrainLod::Lod1)
+ .map(|a| Some(a.node_index))
+ .collect::<Vec<_>>();
+ ranges.extend(split_ranges(&mut far_mesh, &far_nodes, TerrainLod::Lod1)?);
+ let vertex_offset = u32::try_from(near_mesh.vertices.len())
+ .map_err(|_| "terrain LOD vertex offset exceeds u32".to_string())?;
+ let index_offset = u32::try_from(near_mesh.indices.len())
+ .map_err(|_| "terrain LOD index offset exceeds u32".to_string())?;
+ for index in &mut far_mesh.indices {
+ *index = index
+ .checked_add(vertex_offset)
+ .ok_or_else(|| "far terrain vertex index overflows".to_string())?;
+ }
+ for range in &mut far_mesh.draw_ranges {
+ range.first_index = range
+ .first_index
+ .checked_add(index_offset)
+ .ok_or_else(|| "far terrain draw range overflows".to_string())?;
+ }
+ near_mesh.vertices.extend(far_mesh.vertices);
+ near_mesh.indices.extend(far_mesh.indices);
+ near_mesh.draw_ranges.extend(far_mesh.draw_ranges);
+ }
+ Ok((near_mesh, Some(TerrainLodDraws { plan, ranges })))
+}
+
+fn split_ranges(
+ mesh: &mut VulkanStaticMesh,
+ nodes: &[Option<usize>],
+ lod: TerrainLod,
+) -> Result<Vec<(Option<usize>, TerrainLod)>, String> {
+ if mesh.indices.len() / 3 != nodes.len() || !mesh.indices.len().is_multiple_of(3) {
+ return Err("terrain LOD triangle metadata does not match the index buffer".to_string());
+ }
+ let mut ranges = Vec::<VulkanStaticDrawRange>::new();
+ let mut bindings = Vec::new();
+ for &range in &mesh.draw_ranges {
+ if !range.first_index.is_multiple_of(3) || !range.index_count.is_multiple_of(3) {
+ return Err("terrain LOD range is not triangle aligned".to_string());
+ }
+ let first = range.first_index as usize / 3;
+ let end = first
+ .checked_add(range.index_count as usize / 3)
+ .filter(|&end| end <= nodes.len())
+ .ok_or_else(|| "terrain LOD range is outside triangle metadata".to_string())?;
+ let mut start = first;
+ while start < end {
+ let node = nodes[start];
+ let mut next = start + 1;
+ while next < end && nodes[next] == node {
+ next += 1;
+ }
+ let mut split = range;
+ split.first_index =
+ u32::try_from(start * 3).map_err(|_| "terrain LOD index overflows".to_string())?;
+ split.index_count = u32::try_from((next - start) * 3)
+ .map_err(|_| "terrain LOD count overflows".to_string())?;
+ ranges.push(split);
+ bindings.push((node, lod));
+ start = next;
+ }
+ }
+ mesh.draw_ranges = ranges;
+ Ok(bindings)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_render_vulkan::VulkanStaticVertex;
+
+ #[test]
+ #[ignore = "requires licensed GOG Constructor resources"]
+ fn gog_constructor_lod0_cells_match_native_basement_mask() {
+ use fparkan_runtime::*;
+ use std::{num::NonZeroUsize, sync::Arc};
+ let root = std::env::var_os("FPARKAN_GAME_ROOT")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
+ let mut engine = create(EngineServices::new(Arc::new(
+ fparkan_vfs::DirectoryVfs::new(root),
+ )))
+ .unwrap();
+ load_mission_static_preview_roots(
+ &mut engine,
+ MissionRequest {
+ key: r"MISSIONS\CAMPAIGN\CAMPAIGN.00\Mission.02\data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ NonZeroUsize::new(usize::MAX).unwrap(),
+ )
+ .unwrap();
+ let assets = loaded_mission_assets(&engine).unwrap();
+ let roots = loaded_mission_object_drafts(&engine).unwrap();
+ let terrain = loaded_terrain(&engine).unwrap().source_mesh().unwrap();
+ let footprints = super::super::mission_basement_footprints(assets, roots).unwrap();
+ let borrowed = footprints
+ .iter()
+ .map(|f| fparkan_terrain::BasementFootprint {
+ outer: &f.outer,
+ inner: &f.inner,
+ flatten_inner_to_mean_outer_z: f.flatten_inner_to_mean_outer_z,
+ })
+ .collect::<Vec<_>>();
+ let mut foundation =
+ fparkan_terrain::build_basement_foundations(terrain, &borrowed).unwrap();
+ let source =
+ fparkan_render_vulkan::project_land_msh_to_static_mesh_in_world_space(terrain).unwrap();
+ let (near, _) =
+ super::super::project_basement_foundation(terrain, &source, &mut foundation).unwrap();
+ let plan = TerrainLodPlan::new(terrain, Some(&foundation)).unwrap();
+ let forced = (0..terrain.nodes_raw.len() / 38)
+ .filter(|&node| plan.selected_lod(node, [-10_000.0; 2], 1.3) == Some(TerrainLod::Lod0))
+ .collect::<Vec<_>>();
+ // GOG Terrain.dll (hash in original-binaries.md), live Render +0x11A19:
+ // cells whose patch list at Render-this +0x7A30 has a nonzero count.
+ assert_eq!(
+ forced,
+ [
+ 54, 55, 56, 57, 70, 71, 72, 73, 86, 87, 113, 114, 115, 129, 130, 131, 146, 147,
+ 183, 184, 199, 200
+ ]
+ );
+ let (mesh, draws) = prepare(terrain, near, Some(&foundation)).unwrap();
+ let draws = draws.unwrap();
+ let mut checked = [false; 2];
+ for (range, &(node, lod)) in draws.ranges.iter().enumerate() {
+ if node != Some(40) {
+ continue;
+ }
+ let count = mesh.draw_ranges[range].index_count;
+ // Native cell 40 selects far at camera cell [2, 5], near at [2, 7].
+ assert_eq!(
+ draws
+ .index_count(range, count, [300.42047, 556.282, 155.91914], 1.3)
+ .unwrap(),
+ if lod == TerrainLod::Lod1 { count } else { 0 }
+ );
+ assert_eq!(
+ draws
+ .index_count(range, count, [265.0, 795.0, 155.91914], 1.3)
+ .unwrap(),
+ if lod == TerrainLod::Lod0 { count } else { 0 }
+ );
+ checked[usize::from(lod == TerrainLod::Lod1)] = true;
+ }
+ assert_eq!(checked, [true, true]);
+ }
+
+ #[test]
+ fn cell_ranges_keep_material_state_and_separate_generated_bands() {
+ let range = VulkanStaticDrawRange {
+ first_index: 0,
+ index_count: 12,
+ material_index: 7,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ terrain_base_only: true,
+ pipeline_state: super::super::world_pipeline_state(
+ fparkan_render::LegacyBlendMode::Opaque,
+ fparkan_render::LegacyDepthMode::TestWrite,
+ ),
+ alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
+ };
+ let mut mesh = VulkanStaticMesh {
+ vertices: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
+ .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,
+ })
+ .to_vec(),
+ indices: vec![0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2],
+ draw_ranges: vec![range],
+ };
+ let nodes = [Some(4), Some(4), None, Some(5)];
+ let bindings = split_ranges(&mut mesh, &nodes, TerrainLod::Lod0).unwrap();
+ assert_eq!(
+ bindings,
+ vec![
+ (Some(4), TerrainLod::Lod0),
+ (None, TerrainLod::Lod0),
+ (Some(5), TerrainLod::Lod0)
+ ]
+ );
+ assert_eq!(
+ mesh.draw_ranges
+ .iter()
+ .map(|r| (r.first_index, r.index_count))
+ .collect::<Vec<_>>(),
+ vec![(0, 6), (6, 3), (9, 3)]
+ );
+ for split in &mesh.draw_ranges {
+ assert_eq!(split.material_index, 7);
+ assert_eq!(split.pipeline_state, range.pipeline_state);
+ assert!(split.terrain_base_only);
+ }
+ assert!(split_ranges(&mut mesh, &nodes[..3], TerrainLod::Lod0).is_err());
+ }
+}
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
index e9d52e9..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};
@@ -2112,6 +2112,33 @@ impl<R: ResourceRepository> AssetManager<R> {
) -> Result<MissionAssetPlan, AssetError> {
build_mission_asset_plan_with_repository(&self.repository, prototypes)
}
+
+ /// Reads raw opcodes from the validated initial Control effect group.
+ ///
+ /// A complete no-effect section or an out-of-range raw initial-group
+ /// selector returns `Some(Vec::new())`; unsupported shapes return `None`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AssetError`] when the Control resource is malformed.
+ pub fn initial_control_effect_opcodes(
+ &self,
+ source: &ResourceKey,
+ model_node_count: usize,
+ prototype_index: usize,
+ ) -> Result<Option<Vec<u32>>, AssetError> {
+ let Some(payload) = read_optional_key(&self.repository, source, Some("component control"))?
+ else {
+ return Ok(None);
+ };
+ let (_, opcodes) = decode_component_control_payload_with_opcodes(
+ &payload,
+ source.clone(),
+ model_node_count,
+ prototype_index,
+ )?;
+ Ok(opcodes)
+ }
}
/// Produces a count-only plan from a prototype graph.
@@ -3867,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;
@@ -3881,6 +3908,7 @@ const CONTROL_EFFECT_ROW_BYTES: usize = 100;
struct DecodedComponentEffectSection {
section: PreparedControlEffectSection,
initial_effect_group: Option<usize>,
+ initial_group_opcodes: Option<Vec<u32>>,
typed_records: Vec<PreparedControlTypedRecord>,
}
@@ -3905,6 +3933,7 @@ where
}
},
initial_effect_group: None,
+ initial_group_opcodes: (cursor == payload.len()).then(Vec::new),
typed_records: Vec::new(),
});
}
@@ -3926,6 +3955,7 @@ where
typed_record_tag: Some(tag),
},
initial_effect_group: None,
+ initial_group_opcodes: None,
typed_records,
});
}
@@ -4015,8 +4045,9 @@ where
}
if effect_group_count == 0 {
+ let complete = cursor == payload.len();
return Ok(DecodedComponentEffectSection {
- section: if cursor == payload.len() {
+ section: if complete {
PreparedControlEffectSection::Parsed(Vec::new())
} else {
PreparedControlEffectSection::Unsupported {
@@ -4025,6 +4056,7 @@ where
}
},
initial_effect_group: None,
+ initial_group_opcodes: complete.then(Vec::new),
typed_records,
});
}
@@ -4043,6 +4075,7 @@ where
.filter(|group| *group < effect_group_count);
cursor = group_header_end;
+ let mut initial_group_opcodes = Some(Vec::new());
let mut effect_rows = Vec::new();
for table_index in 0..effect_group_count {
let row_count = usize::try_from(
@@ -4069,7 +4102,14 @@ where
for row_index in 0..row_count {
let row_start = rows_start + row_index * CONTROL_EFFECT_ROW_BYTES;
let row = &payload[row_start..row_start + CONTROL_EFFECT_ROW_BYTES];
- if control_u32_at(row, 0x0C) != Some(4) {
+ let opcode = control_u32_at(row, 0x0C).expect("fixed effect row has opcode");
+ if initial_effect_group == Some(table_index) {
+ initial_group_opcodes
+ .as_mut()
+ .expect("valid initial group has an opcode list")
+ .push(opcode);
+ }
+ if opcode != 4 {
continue;
}
let raw_condition = [
@@ -4108,12 +4148,14 @@ where
typed_record_tag: None,
},
initial_effect_group: None,
+ initial_group_opcodes: None,
typed_records,
});
}
Ok(DecodedComponentEffectSection {
section: PreparedControlEffectSection::Parsed(effect_rows),
initial_effect_group,
+ initial_group_opcodes,
typed_records,
})
}
@@ -4130,6 +4172,21 @@ fn decode_component_control_payload(
model_node_count: usize,
prototype_index: usize,
) -> Result<PreparedComponentControl, AssetError> {
+ decode_component_control_payload_with_opcodes(
+ payload,
+ source,
+ model_node_count,
+ prototype_index,
+ )
+ .map(|(control, _)| control)
+}
+
+fn decode_component_control_payload_with_opcodes(
+ payload: &[u8],
+ source: ResourceKey,
+ model_node_count: usize,
+ prototype_index: usize,
+) -> Result<(PreparedComponentControl, Option<Vec<u32>>), AssetError> {
const HEADER_BYTES: usize = 5 * 4;
const FIXED_SECTION_BYTES: usize = 108;
const SECTION_FIXED_BYTES: usize = 156;
@@ -4267,18 +4324,22 @@ fn decode_component_control_payload(
&invalid,
)?;
- Ok(PreparedComponentControl {
- source,
- native_profile_words,
- model_node_count,
- bindings,
- timeline_rows,
- typed_records: decoded_effect.typed_records,
- node_data: None,
- effect_section: decoded_effect.section,
- initial_effect_group: decoded_effect.initial_effect_group,
- point_list: None,
- })
+ let initial_group_opcodes = decoded_effect.initial_group_opcodes;
+ Ok((
+ PreparedComponentControl {
+ source,
+ native_profile_words,
+ model_node_count,
+ bindings,
+ timeline_rows,
+ typed_records: decoded_effect.typed_records,
+ node_data: None,
+ effect_section: decoded_effect.section,
+ initial_effect_group: decoded_effect.initial_effect_group,
+ point_list: None,
+ },
+ initial_group_opcodes,
+ ))
}
fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> {
@@ -4377,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;
@@ -6534,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"),
@@ -6677,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"),
@@ -8327,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-fx/src/ambient_sound.rs b/crates/fparkan-fx/src/ambient_sound.rs
new file mode 100644
index 0000000..bcc1814
--- /dev/null
+++ b/crates/fparkan-fx/src/ambient_sound.rs
@@ -0,0 +1,288 @@
+//! Opcode-2 sound commands, independent of an audio device.
+
+use crate::environment::{SoundAction, SoundEvent};
+use crate::{FxCommand, FxOpcode};
+
+/// A validated ordinary Op2 command. Modes 1/3 request an unverified backend
+/// flag and are left to the caller's unsupported-command report.
+#[derive(Clone, Debug)]
+pub struct Op2SoundCommand {
+ looping: bool,
+ start: f32,
+ end: f32,
+ position: [f32; 3],
+ position_delta: [f32; 3],
+ min_distance: f32,
+ max_distance: f32,
+ frequency: f32,
+ frequency_delta: f32,
+ archive: String,
+ name: String,
+}
+
+/// Playback crossing state belongs to a command instance, even when several
+/// instances share the same sample.
+#[derive(Clone, Debug, Default)]
+pub struct Op2SoundState {
+ previous_phase: f32,
+}
+
+impl Op2SoundCommand {
+ /// Decodes the supported native modes 0 (once) and 2 (loop).
+ #[must_use]
+ pub fn from_command(command: &FxCommand) -> Option<Self> {
+ let body = command.raw_body.as_ref();
+ if command.opcode != FxOpcode::Op2 || body.len() != 144 {
+ return None;
+ }
+ let mode = u32::from_le_bytes(body[..4].try_into().ok()?);
+ // shortcut: backend flag 0x200 and body+76 need a native playback
+ // comparison before modes 1/3 or nonzero backend parameters are enabled.
+ if !matches!(mode, 0 | 2) || body[76..80] != [0; 4] {
+ return None;
+ }
+ let float = |offset| {
+ let value = f32::from_le_bytes(body[offset..offset + 4].try_into().ok()?);
+ value.is_finite().then_some(value)
+ };
+ let vector = |offset| Some([float(offset)?, float(offset + 4)?, float(offset + 8)?]);
+ let start = float(4)?;
+ let end = float(8)?;
+ let position = vector(12)?;
+ let position_end = vector(24)?;
+ let min_distance = float(60)?;
+ let max_distance = float(64)?;
+ let frequency = float(68)?;
+ let frequency_end = float(72)?;
+ let position_delta = std::array::from_fn(|axis| position_end[axis] - position[axis]);
+ let frequency_delta = frequency_end - frequency;
+ if end <= start
+ || !(end - start).is_finite()
+ || !position_delta.iter().all(|value: &f32| value.is_finite())
+ || !frequency_delta.is_finite()
+ || min_distance < 0.0
+ || max_distance < min_distance
+ || frequency <= 0.0
+ || frequency_end <= 0.0
+ {
+ return None;
+ }
+ let text = |bytes: &[u8]| {
+ let end = bytes
+ .iter()
+ .position(|&byte| byte == 0)
+ .unwrap_or(bytes.len());
+ (!bytes[..end].is_empty()).then(|| String::from_utf8_lossy(&bytes[..end]).into_owned())
+ };
+ Some(Self {
+ looping: mode == 2,
+ start,
+ end,
+ position,
+ position_delta,
+ min_distance,
+ max_distance,
+ frequency,
+ frequency_delta,
+ archive: text(&body[80..112])?,
+ name: text(&body[112..144])?,
+ })
+ }
+
+ /// Evaluates Effect.dll's phase crossings and current source parameters.
+ /// `playing` comes from the audio owner, since a one-shot can finish between
+ /// updates. The matrix maps local command coordinates into world space.
+ #[must_use]
+ pub fn update(
+ &self,
+ state: &mut Op2SoundState,
+ phase: f32,
+ world_from_effect: [[f32; 4]; 4],
+ playing: bool,
+ ) -> Option<SoundEvent> {
+ if !phase.is_finite() || !world_from_effect.iter().flatten().all(|v| v.is_finite()) {
+ return None;
+ }
+ let action = if self.looping {
+ if phase >= self.start && phase <= self.end {
+ if playing {
+ SoundAction::SetLoopVolume
+ } else {
+ SoundAction::StartLoop
+ }
+ } else if playing {
+ SoundAction::StopLoop
+ } else {
+ return None;
+ }
+ } else {
+ let crossing_phase = if phase == 1.0 { 0.0 } else { phase };
+ let crossed = state.previous_phase <= crossing_phase
+ && state.previous_phase <= self.start
+ && self.start < crossing_phase;
+ state.previous_phase = crossing_phase;
+ if crossed {
+ SoundAction::OneShot
+ } else if playing {
+ SoundAction::SetLoopVolume
+ } else {
+ return None;
+ }
+ };
+ let t = (phase - self.start) / (self.end - self.start);
+ let local: [f32; 3] =
+ std::array::from_fn(|axis| self.position[axis] + t * self.position_delta[axis]);
+ let position = std::array::from_fn(|axis| {
+ let row = world_from_effect[axis];
+ row[0] * local[0] + row[1] * local[1] + row[2] * local[2] + row[3]
+ });
+ let frequency_ratio = self.frequency + t * self.frequency_delta;
+ if !position.iter().all(|value| value.is_finite()) || !frequency_ratio.is_finite() {
+ return None;
+ }
+ Some(SoundEvent {
+ archive: self.archive.clone(),
+ name: self.name.clone(),
+ position,
+ volume: 1.0,
+ min_distance: self.min_distance,
+ max_distance: self.max_distance,
+ frequency_ratio,
+ action,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Arc;
+
+ const IDENTITY: [[f32; 4]; 4] = [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+
+ fn command(mode: u32) -> FxCommand {
+ let mut body = vec![0; 144];
+ body[..4].copy_from_slice(&mode.to_le_bytes());
+ for (offset, value) in [
+ (4, 0.25_f32),
+ (8, 0.75),
+ (60, 12.0),
+ (64, 80.0),
+ (68, 1.0),
+ (72, 2.0),
+ ] {
+ body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+ body[80..90].copy_from_slice(b"sounds.lib");
+ body[112..122].copy_from_slice(b"f_defl.wav");
+ FxCommand {
+ word: 2,
+ opcode: FxOpcode::Op2,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: vec![],
+ }
+ }
+
+ #[test]
+ fn loop_has_inclusive_boundaries_and_updates_without_restarting() {
+ let command = Op2SoundCommand::from_command(&command(2)).unwrap();
+ let mut state = Op2SoundState::default();
+ assert!(command.update(&mut state, 0.0, IDENTITY, false).is_none());
+ assert_eq!(
+ command
+ .update(&mut state, 0.25, IDENTITY, false)
+ .unwrap()
+ .action,
+ SoundAction::StartLoop
+ );
+ assert_eq!(
+ command
+ .update(&mut state, 0.75, IDENTITY, true)
+ .unwrap()
+ .action,
+ SoundAction::SetLoopVolume
+ );
+ assert_eq!(
+ command
+ .update(&mut state, 0.8, IDENTITY, true)
+ .unwrap()
+ .action,
+ SoundAction::StopLoop
+ );
+ assert!(command.update(&mut state, 0.8, IDENTITY, false).is_none());
+ }
+
+ #[test]
+ fn one_shot_crossing_is_strict_and_rearms_after_phase_one() {
+ let command = Op2SoundCommand::from_command(&command(0)).unwrap();
+ let mut state = Op2SoundState::default();
+ assert!(command.update(&mut state, 0.25, IDENTITY, false).is_none());
+ assert_eq!(
+ command
+ .update(&mut state, 0.3, IDENTITY, false)
+ .unwrap()
+ .action,
+ SoundAction::OneShot
+ );
+ assert_eq!(
+ command
+ .update(&mut state, 0.5, IDENTITY, true)
+ .unwrap()
+ .action,
+ SoundAction::SetLoopVolume
+ );
+ assert!(command.update(&mut state, 0.6, IDENTITY, false).is_none());
+ assert!(command.update(&mut state, 1.0, IDENTITY, false).is_none());
+ assert_eq!(
+ command
+ .update(&mut state, 0.3, IDENTITY, false)
+ .unwrap()
+ .action,
+ SoundAction::OneShot
+ );
+ }
+
+ #[test]
+ fn parameters_keep_native_constant_ranges_and_transform_the_position() {
+ let mut source = command(2);
+ let mut body = source.raw_body.to_vec();
+ body[24..28].copy_from_slice(&4.0_f32.to_le_bytes());
+ source.raw_body = Arc::from(body);
+ let command = Op2SoundCommand::from_command(&source).unwrap();
+ let mut matrix = IDENTITY;
+ matrix[0] = [0.0, -1.0, 0.0, 10.0];
+ matrix[1] = [2.0, 0.0, 0.0, 20.0];
+ let event = command
+ .update(&mut Op2SoundState::default(), 0.5, matrix, false)
+ .unwrap();
+ assert_eq!(event.position, [10.0, 24.0, 0.0]);
+ assert_eq!(
+ (
+ event.min_distance,
+ event.max_distance,
+ event.frequency_ratio
+ ),
+ (12.0, 80.0, 1.5)
+ );
+ assert_eq!(
+ (event.archive.as_str(), event.name.as_str()),
+ ("sounds.lib", "f_defl.wav")
+ );
+ }
+
+ #[test]
+ fn backend_modes_and_malformed_commands_are_explicitly_unsupported() {
+ assert!(Op2SoundCommand::from_command(&command(1)).is_none());
+ let mut source = command(2);
+ let mut body = source.raw_body.to_vec();
+ body[8..12].copy_from_slice(&f32::NAN.to_le_bytes());
+ source.raw_body = Arc::from(body);
+ assert!(Op2SoundCommand::from_command(&source).is_none());
+ }
+}
diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs
index 68a2c8f..713a3f9 100644
--- a/crates/fparkan-fx/src/environment.rs
+++ b/crates/fparkan-fx/src/environment.rs
@@ -590,7 +590,7 @@ impl LightningBounds {
/// Effect.dll's opcode 1 handler (`1000F6E0`) updates a LightManager record.
/// The A and B groups are the submitted position and direction, C is the
/// four-component HDR light color, the scalar pair at body offsets 108 and
-/// 112 is the light range, and body offsets 120, 124, and 128 are the native
+/// 112 is the local light radius, and body offsets 120, 124, and 128 are the native
/// attenuation coefficients. These values do not describe lightning mesh
/// geometry or texture coordinates.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -615,8 +615,7 @@ pub struct PointLightFrame {
pub range: f32,
/// Native attenuation coefficients `(a0, a1, a2)`.
pub attenuation: [f32; 3],
- /// The scalar at body offset 116. Its native role is an effect factor,
- /// not alpha or a UV parameter, so it is preserved without reinterpretation.
+ /// Radius jitter amplitude at body offset 116; retained but not sampled yet.
pub effect_factor: f32,
/// Whether the opcode 1 command is active at `command_time`.
pub active: bool,
@@ -626,8 +625,8 @@ pub struct PointLightFrame {
/// applies a parent basis to its local position and direction. `basis` stores
/// the world-space vectors corresponding to local +X, +Y, and +Z.
/// `command_time` is resolved by the caller; FX header duration and lifecycle
-/// remain with that caller. The interpolated range is retained as authored;
-/// native Op1 does not clamp it to zero.
+/// remain with that caller. Native Op1 scales the radius by the transformed
+/// direction length and replaces a non-positive result with 0.01.
#[must_use]
pub fn evaluate_op1_point_light_with_basis(
command: &FxCommand,
@@ -677,7 +676,22 @@ pub fn evaluate_op1_point_light_with_basis(
{
return None;
}
- let direction = normalize_op1_direction(transformed_direction);
+ let direction_length = transformed_direction
+ .into_iter()
+ .map(|component| component * component)
+ .sum::<f32>()
+ .sqrt();
+ // shortcut: Op1 color/radius jitter is omitted; add native RNG sampling
+ // when the reported randomized Control lights are handled in phase R4.
+ let scaled_range = sample.range * direction_length;
+ if !direction_length.is_finite() || !scaled_range.is_finite() {
+ return None;
+ }
+ let direction = if direction_length > 0.0 {
+ transformed_direction.map(|component| component / direction_length)
+ } else {
+ [0.0; 3]
+ };
Some(PointLightFrame {
effect_type: sample.effect_type,
@@ -688,7 +702,11 @@ pub fn evaluate_op1_point_light_with_basis(
position,
direction,
color: sample.color,
- range: sample.range,
+ range: if scaled_range <= 0.0 {
+ 0.01
+ } else {
+ scaled_range
+ },
attenuation: sample.attenuation,
effect_factor: sample.effect_factor,
active: true,
@@ -722,19 +740,6 @@ fn sample_op1_point_light(body: &[u8], normalized_time: f32) -> Option<Op1PointL
})
}
-fn normalize_op1_direction(direction: [f32; 3]) -> [f32; 3] {
- // Native FastProc normalization preserves a degenerate vector as zero;
- // this path must not invent a fallback axis.
- let length =
- (direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2])
- .sqrt();
- if length.is_finite() && length > 0.0 {
- direction.map(|component| component / length)
- } else {
- [0.0; 3]
- }
-}
-
fn lightning_point_light_direction(local_direction: [f32; 3], basis: [[f32; 3]; 3]) -> [f32; 3] {
// Preserve the pre-existing Lightning consumer fallback, which is
// separate from the generic native Op1 behavior.
@@ -2964,7 +2969,7 @@ mod tests {
}
#[test]
- fn generic_opcode1_preserves_negative_native_range() {
+ fn generic_opcode1_clamps_non_positive_scaled_range() {
let mut command = op1_test_command(5, 0.0, 1.0);
let mut body = command.raw_body.to_vec();
body[108..112].copy_from_slice(&(-2.0_f32).to_le_bytes());
@@ -2973,7 +2978,7 @@ mod tests {
let sample = evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3])
.expect("native Op1 sample with a negative range");
- assert_eq!(sample.range, -3.0);
+ assert_eq!(sample.range, 0.01);
}
#[test]
@@ -2986,6 +2991,31 @@ mod tests {
let sample = evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3])
.expect("native Op1 sample with a zero direction");
assert_eq!(sample.direction, [0.0; 3]);
+ assert_eq!(sample.range, 0.01);
+ }
+
+ #[test]
+ fn generic_opcode1_scales_radius_like_live_factory_light() {
+ // Live Effect.dll Op1: authored radius 7, FastProc direction length
+ // 0x409b1f91, submitted LightManager radius 0x4207bb9f.
+ let mut command = op1_test_command(7, 0.75, 1.0);
+ let mut body = command.raw_body.to_vec();
+ body[48..60].copy_from_slice(&[1.0_f32, 0.0, 0.0].map(f32::to_le_bytes).concat());
+ body[112..116].copy_from_slice(&7.0_f32.to_le_bytes());
+ command.raw_body = Arc::from(body.into_boxed_slice());
+ let sample = evaluate_op1_point_light_with_basis(
+ &command,
+ 1.0,
+ [0.0; 3],
+ [
+ [f32::from_bits(0x409b_1f91), 0.0, 0.0],
+ [0.0, 1.0, 0.0],
+ [0.0, 0.0, 1.0],
+ ],
+ )
+ .expect("live factory radius");
+ assert_eq!(sample.range.to_bits(), 0x4207_bb9f);
+ assert_eq!(sample.direction, [1.0, 0.0, 0.0]);
}
#[test]
diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs
index 4125a68..af7408a 100644
--- a/crates/fparkan-fx/src/lib.rs
+++ b/crates/fparkan-fx/src/lib.rs
@@ -7,6 +7,7 @@
//! are backed by runtime-captured evidence.
pub mod ambient_op3;
+pub mod ambient_sound;
pub mod atmosphere;
pub mod environment;
pub mod fx_visual;
diff --git a/crates/fparkan-fx/src/sky.rs b/crates/fparkan-fx/src/sky.rs
index 26e68ab..1b793f0 100644
--- a/crates/fparkan-fx/src/sky.rs
+++ b/crates/fparkan-fx/src/sky.rs
@@ -328,6 +328,24 @@ pub struct SkyMesh {
}
impl SkyMesh {
+ /// Updates native nebula, star and cloud scroll from the sky object's elapsed milliseconds.
+ pub fn update_uvs(&mut self, elapsed_ms: u32) {
+ let offsets = [
+ [(elapsed_ms % 300_000) as f32 * 3.333_333_3e-6; 2],
+ [(elapsed_ms % 100_000) as f32 * 1.0e-5; 2],
+ [(elapsed_ms % 50_000) as f32 * 2.0e-5, 0.0],
+ ];
+ let inverse_radius = self.radius.recip();
+ for vertex in &mut self.vertices {
+ for stage in 0..3 {
+ let scale = inverse_radius * SKY_UV_SCALES[stage];
+ vertex.uv[stage] = std::array::from_fn(|axis| {
+ scale * vertex.position[axis] + offsets[stage][axis]
+ });
+ }
+ }
+ }
+
/// Number of triangles in the mesh.
#[must_use]
pub fn triangle_count(&self) -> usize {
@@ -1143,12 +1161,11 @@ impl SunFrame {
/// Converts an interpolated atmosphere sun sample to a renderer-facing frame.
///
/// The original update computes `orbit_direction = [cos(theta), 0,
-/// -sin(theta)]`, then applies the row-vector product of the two birth-angle
+/// -sin(theta)]`, then applies the column-vector product of the two birth-angle
/// matrices. With `A` and `B` in radians, the resulting direction is:
///
/// ```text
-/// [x*cos(A), -x*sin(A)*cos(B) + z*sin(B),
-/// x*sin(A)*sin(B) + z*cos(B)]
+/// [x*cos(A) + z*sin(A)*sin(B), x*sin(A) - z*cos(A)*sin(B), z*cos(B)]
/// ```
///
/// where `x` and `z` are the two non-zero orbit components.
@@ -1178,9 +1195,9 @@ pub fn sun_frame(sample: &SunSample, now_seconds: f32, config: SunConfig) -> Sun
let x = orbit_direction[0];
let z = orbit_direction[2];
let direction = normalize3([
- x * cos_a,
- -x * sin_a * cos_b + z * sin_b,
- x * sin_a * sin_b + z * cos_b,
+ x * cos_a + z * sin_a * sin_b,
+ x * sin_a - z * cos_a * sin_b,
+ z * cos_b,
]);
SunFrame {
active: true,
@@ -1788,6 +1805,26 @@ mod tests {
}
#[test]
+ fn scrolling_uvs_match_native_periods_and_wrapped_elapsed_clock() {
+ let mut mesh = SkyGeometryConfig::default().try_mesh(&sample()).unwrap();
+ let topology = mesh.indices.clone();
+ for elapsed in [0, 25_000, 49_999, 50_000, 100_000, 300_000, 4_051_271_258] {
+ mesh.update_uvs(elapsed);
+ let zenith = mesh.vertices[0].uv;
+ assert_eq!(zenith[0], [(elapsed % 300_000) as f32 * 3.333_333_3e-6; 2]);
+ assert_eq!(zenith[1], [(elapsed % 100_000) as f32 * 1e-5; 2]);
+ assert_eq!(zenith[2], [(elapsed % 50_000) as f32 * 2e-5, 0.0]);
+ }
+ // Live GOG sky render: now 265584022, object origin 509280060.
+ mesh.update_uvs(265_584_022_u32.wrapping_sub(509_280_060));
+ assert!((mesh.vertices[0].uv[2][0] - 0.42516).abs() < 1e-7);
+ let once = mesh.vertices.clone();
+ mesh.update_uvs(4_051_271_258);
+ assert_eq!(mesh.vertices, once, "updates must not accumulate drift");
+ assert_eq!(mesh.indices, topology);
+ }
+
+ #[test]
fn geometry_matches_original_counts_positions_uv_and_winding() {
let config = SkyGeometryConfig::new(10.0, std::f32::consts::FRAC_PI_2, 2.0, 4, 3);
let mesh = config.try_mesh(&sample()).expect("valid mesh");
@@ -1892,16 +1929,30 @@ mod tests {
let sample = sun(true);
let at_start = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [90.0, 0.0]));
assert!((at_start.direction[0]).abs() < 1.0e-5);
- assert!((at_start.direction[1] + at_start.orbit_direction[0]).abs() < 1.0e-5);
+ assert!((at_start.direction[1] - at_start.orbit_direction[0]).abs() < 1.0e-5);
assert!((at_start.direction[2] - at_start.orbit_direction[2]).abs() < 1.0e-5);
let side = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [0.0, 90.0]));
assert!((side.direction[0] - side.orbit_direction[0]).abs() < 1.0e-5);
- assert!((side.direction[1] - side.orbit_direction[2]).abs() < 1.0e-5);
+ assert!((side.direction[1] + side.orbit_direction[2]).abs() < 1.0e-5);
assert!(side.direction[2].abs() < 1.0e-5);
}
#[test]
+ fn sun_direction_matches_live_native_birth_matrix() {
+ // Terrain CSun::Update, elapsed 282184 ms, lifetime 525000 ms.
+ let frame = sun_frame(
+ &sun(true),
+ 282.184,
+ SunConfig::new(0.0, 525.0, [90.0, 30.0]),
+ );
+ let expected = [0xbefd_7269, 0xbe10_4168, 0xbf5b_7dda].map(f32::from_bits);
+ for (actual, expected) in frame.direction.into_iter().zip(expected) {
+ assert!((actual - expected).abs() < 3.0e-6, "{actual} != {expected}");
+ }
+ }
+
+ #[test]
fn native_birth_keeps_the_real_start_time() {
assert!(!celestial_frame(&sun(false), 100.0).active);
let birth = SunBirth {
diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs
index 08a285d..ad96204 100644
--- a/crates/fparkan-render/src/lib.rs
+++ b/crates/fparkan-render/src/lib.rs
@@ -361,6 +361,64 @@ pub enum LegacyCullMode {
FrontFace,
}
+/// CPU diffuse alpha for a colourless MSH batch carrying flag `0x0008`.
+///
+/// `packet_distance` is the distance to the packet's first accepted vertex,
+/// after its object transform. Flag `0x0010` takes precedence over `0x0040`.
+/// Returns `None` for invalid distance or projection inputs. Callers must keep
+/// the material alpha for batches with a type-16 colour stream or without `0x0008`.
+#[must_use]
+pub fn legacy_msh_portal_alpha(
+ batch_flags: u16,
+ packet_distance: f32,
+ horizontal_fov: f32,
+) -> Option<f32> {
+ if !packet_distance.is_finite()
+ || packet_distance < 0.0
+ || !horizontal_fov.is_finite()
+ || horizontal_fov <= 0.0
+ {
+ return None;
+ }
+ let (distance, near, far, slope) = if batch_flags & 0x0010 != 0 {
+ (
+ packet_distance.sqrt(),
+ 2.6 / horizontal_fov,
+ 7.799_999_7 / horizontal_fov,
+ (1.0 / (7.799_999_7 - 2.6)) * horizontal_fov,
+ )
+ } else if batch_flags & 0x0040 != 0 {
+ return Some(0.0);
+ } else {
+ let near = 75.0 * 1.3;
+ let far = 95.0 * 1.3;
+ (
+ packet_distance,
+ horizontal_fov.recip() * near,
+ horizontal_fov.recip() * far,
+ (1.0 / (far - near)) * horizontal_fov,
+ )
+ };
+ Some(if distance < near {
+ 0.0
+ } else if distance > far {
+ 1.0
+ } else {
+ ((distance - near) * slope).clamp(0.0, 1.0)
+ })
+}
+
+/// Raster depth adjustment applied to one pipeline variant.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum LegacyDepthBias {
+ /// Preserve the interpolated depth exactly.
+ #[default]
+ Disabled,
+ /// Move projected decals toward the camera so they pass against their
+ /// coplanar receiver under a less-or-equal depth test.
+ ProjectedDecal,
+}
+
/// Legacy fixed-function state that changes graphics-pipeline structure.
///
/// Alpha reference is deliberately not present: it is dynamic material data,
@@ -375,6 +433,8 @@ pub struct LegacyPipelineState {
pub cull: LegacyCullMode,
/// Whether alpha-test shader logic is enabled.
pub alpha_test: bool,
+ /// Raster depth bias for projected decals.
+ pub depth_bias: LegacyDepthBias,
}
impl LegacyPipelineState {
@@ -405,15 +465,16 @@ impl LegacyPipelineState {
/// Canonical, backend-neutral key for a graphics-pipeline variant.
///
/// The value is explicitly packed rather than hashed, so captures and caches
-/// remain stable across processes and Rust toolchain updates. Three bits are
-/// reserved for the five native material blend modes.
+/// remain stable across processes and Rust toolchain updates. Bits 0..7 encode
+/// blend, depth, cull and alpha-test state; bit 8 distinguishes projected
+/// decal depth bias.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct PipelineKey(u8);
+pub struct PipelineKey(u16);
impl PipelineKey {
/// Returns the canonical packed representation.
#[must_use]
- pub const fn packed(self) -> u8 {
+ pub const fn packed(self) -> u16 {
self.0
}
}
@@ -437,7 +498,11 @@ impl From<LegacyPipelineState> for PipelineKey {
LegacyCullMode::BackFace => 1,
LegacyCullMode::FrontFace => 2,
};
- Self(blend | (depth << 3) | (cull << 5) | (u8::from(state.alpha_test) << 7))
+ let depth_bias = match state.depth_bias {
+ LegacyDepthBias::Disabled => 0,
+ LegacyDepthBias::ProjectedDecal => 1 << 8,
+ };
+ Self(blend | (depth << 3) | (cull << 5) | (u16::from(state.alpha_test) << 7) | depth_bias)
}
}
@@ -445,6 +510,40 @@ impl From<LegacyPipelineState> for PipelineKey {
mod tests {
use super::*;
+ #[test]
+ fn portal_alpha_matches_native_packet_distance_vectors() {
+ // GOG Terrain+1B0F0 with its real +246A0/+247B0 packet methods,
+ // Ngi32 RSQRTSS dispatch, x87 CW=007F, FOV=1.04.
+ for (distance, flags, expected) in [
+ (0.499_938_96, 0x18, 0.0),
+ (4.999_542, 0x18, 0.0),
+ (9.999_084, 0x18, 0.132_426_6),
+ (25.000_572, 0x18, 0.500_011_44),
+ (59.999_084, 0x18, 1.0),
+ (85.012_48, 0x08, 0.0),
+ (109.994_125, 0x08, 0.649_765_2),
+ (25.000_572, 0x58, 0.500_011_44),
+ (110.0, 0x48, 0.0),
+ ] {
+ let actual = legacy_msh_portal_alpha(flags, distance, 1.04).unwrap();
+ assert!(
+ (actual - expected).abs() < 0.000_002,
+ "{distance}: {actual}"
+ );
+ }
+ assert_eq!(legacy_msh_portal_alpha(8, 60.0, 1.3), Some(0.0));
+ assert!((legacy_msh_portal_alpha(8, 85.0, 1.3).unwrap() - 0.5).abs() < 0.000_001);
+ assert_eq!(legacy_msh_portal_alpha(8, 110.0, 1.3), Some(1.0));
+ for (distance, fov) in [
+ (f32::NAN, 1.3),
+ (-1.0, 1.3),
+ (1.0, 0.0),
+ (1.0, f32::INFINITY),
+ ] {
+ assert_eq!(legacy_msh_portal_alpha(8, distance, fov), None);
+ }
+ }
+
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
let mut result = [0.0; 16];
for row in 0..4 {
@@ -614,6 +713,24 @@ mod tests {
}
#[test]
+ fn projected_decal_bias_is_a_distinct_preserved_pipeline_state() {
+ let ordinary = LegacyPipelineState::default();
+ let decal = LegacyPipelineState {
+ depth_bias: LegacyDepthBias::ProjectedDecal,
+ ..ordinary
+ };
+ assert_eq!(ordinary.depth_bias, LegacyDepthBias::Disabled);
+ assert_ne!(PipelineKey::from(ordinary), PipelineKey::from(decal));
+ assert_eq!(
+ decal
+ .with_material_category(1)
+ .expect("native category is represented")
+ .depth_bias,
+ LegacyDepthBias::ProjectedDecal
+ );
+ }
+
+ #[test]
fn native_material_categories_have_distinct_pipeline_keys() {
let base = LegacyPipelineState {
depth: LegacyDepthMode::TestWrite,
diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml
index 8eb3e84..80e5b78 100644
--- a/crates/fparkan-runtime/Cargo.toml
+++ b/crates/fparkan-runtime/Cargo.toml
@@ -7,6 +7,7 @@ repository.workspace = true
[dependencies]
fparkan-assets = { path = "../fparkan-assets" }
+fparkan-fx = { path = "../fparkan-fx" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-path = { path = "../fparkan-path" }
fparkan-platform = { path = "../fparkan-platform" }
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 5595b28..5cc2d86 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -1,26 +1,29 @@
#![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::{
FormulaTable, Handler19InitInput, ScriptInitResolution, ScriptInitResolveError,
ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSet, VarSetDefault,
@@ -43,6 +46,7 @@ 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;
@@ -86,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.
@@ -194,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
@@ -214,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`.
@@ -485,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 {
@@ -670,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 {
@@ -826,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>,
@@ -953,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>,
@@ -1024,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.
@@ -1031,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.
@@ -1187,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}"),
@@ -1226,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),
@@ -1237,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 { .. }
@@ -1258,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
///
@@ -1273,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),
)
}
@@ -1338,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)?;
@@ -1390,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,
@@ -1598,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)
@@ -1612,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,
@@ -1619,6 +2017,7 @@ fn load_mission_with_options(
prototype_span,
&resolved_prototypes,
native_control_class,
+ &mut resolve_factory2_pair,
);
match projection {
Ok(projection) => MissionControlDeviceTableState {
@@ -1662,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,
@@ -1741,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,
@@ -1822,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> {
@@ -2062,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
@@ -2088,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,
@@ -4131,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 {
@@ -4141,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) {
@@ -4177,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 {
@@ -4193,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)
}
@@ -5551,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,
@@ -5559,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,
@@ -5566,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(
@@ -5659,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,
@@ -5666,6 +6267,7 @@ fn build_control_device_projection(
prototype_span,
prototypes,
native_class,
+ &mut resolve_factory2_pair,
)
}
@@ -5676,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;
@@ -5984,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;
@@ -6024,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);
}
}
@@ -6052,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);
}
@@ -6367,6 +6989,60 @@ fn load_script_formula_table(
}
}
+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,
@@ -6375,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") {
@@ -6426,6 +7150,7 @@ fn load_mission_scripts(
};
Ok(LoadedMissionScripts {
bundles,
+ ai_startup_states,
varset,
init_states,
varset_states,
@@ -6668,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 {
@@ -6717,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>,
@@ -7081,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,
@@ -7125,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,
@@ -8025,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![
@@ -9666,6 +11715,7 @@ mod tests {
};
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()
};
@@ -9695,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");
@@ -9705,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));
@@ -10072,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,
@@ -10079,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
@@ -11306,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)],
@@ -11551,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);
@@ -12247,6 +14558,83 @@ 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) {
@@ -12266,79 +14654,6 @@ mod tests {
bytes.extend_from_slice(value);
}
- fn build_nres(entries: &[(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, attr3, payload)) in entries.iter().enumerate() {
- push_u32(&mut bytes, *type_id);
- push_u32(&mut bytes, *attr1);
- push_u32(&mut bytes, 0);
- push_u32(
- &mut bytes,
- u32::try_from(payload.len()).expect("payload fits u32"),
- );
- push_u32(&mut bytes, *attr3);
- let mut name = [0; 36];
- name[..4].copy_from_slice(b"Test");
- bytes.extend_from_slice(&name);
- push_u32(&mut bytes, offsets[index]);
- 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());
- bytes[8..12].copy_from_slice(
- &u32::try_from(entries.len())
- .expect("count fits u32")
- .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> {
- let streams = [
- (1, 0, 38, Vec::new()),
- (2, 0, 0, vec![0; 0x8c]),
- (3, 0, 12, Vec::new()),
- (4, 0, 4, Vec::new()),
- (5, 0, 4, Vec::new()),
- (18, 0, 4, Vec::new()),
- (14, 0, 4, Vec::new()),
- (11, 0, 4, Vec::new()),
- (21, 0, 28, Vec::new()),
- ];
- build_nres(&streams)
- }
-
- 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] {
- push_f32(&mut payload, value);
- }
- for value in [0, 0, 7, 0, 2, 0] {
- push_u32(&mut payload, value);
- }
- for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0] {
- push_f32(&mut payload, value);
- }
- for _ in 0..2 {
- push_i32(&mut payload, -1);
- push_i32(&mut payload, -1);
- }
- push_u32(&mut payload, 1);
- 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, payload)])
- }
-
fn synthetic_mission() -> Vec<u8> {
let mut bytes = Vec::new();
push_u32(&mut bytes, 1);
@@ -12407,12 +14722,14 @@ mod tests {
(
"MISSIONS/SCRIPTS/formula.var",
Arc::from(
- &b"VAR(DWORD, ClanBaseX, 0)\nVAR(DWORD, ClanBaseY, 0)\nVAR(DWORD, ClanID, 0)\nVAR(float, target, 0.5)\n"[..],
+ &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 literal\n\nFUNCTION( , 0.8, )\nFUNCTION( later, target + 2, )\n"[..]),
+ Arc::from(
+ &b"// authored Init expression\n\nFUNCTION( , ClanID + 0.8, )\nFUNCTION( later, target * 2, )\n"[..],
+ ),
),
] {
files.insert(
@@ -12428,7 +14745,10 @@ mod tests {
key: "MISSIONS/TEST/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect("full load applies the authored numeric literal");
@@ -12446,6 +14766,185 @@ mod tests {
}
#[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();
personal_files.insert(
@@ -12612,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,
@@ -12632,7 +15133,11 @@ mod tests {
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(),
},
],
@@ -12645,7 +15150,7 @@ mod tests {
base_path: "MISSIONS/SCRIPTS/default".to_string(),
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
package: package.clone(),
- formula_table: None,
+ formula_table: Some(formula_table.clone()),
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
};
@@ -12663,9 +15168,10 @@ mod tests {
preview_bundles[0].init_execution,
MissionScriptInitExecution::Unsupported {
event_index: 0,
- instruction_index: 1,
- error: ScriptInitResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index: 2,
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression: b" x * 2".to_vec(),
},
}
);
@@ -12694,12 +15200,13 @@ mod tests {
Err(EngineError::ScriptInitUnsupported {
clan_index: 0,
event_index: 0,
- instruction_index: 1,
- source: ScriptInitResolveError::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();
@@ -12903,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");
@@ -13114,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();
@@ -13135,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"),
@@ -13146,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| {
@@ -13255,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");
@@ -14079,8 +16926,15 @@ mod tests {
);
assert!(loaded.script_bundle_count >= 1);
- let full = load_mission(&mut engine, campaign_request(), 100_000)
- .expect("full load licensed campaign mission");
+ 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);
@@ -14127,7 +16981,10 @@ mod tests {
key: "MISSIONS/Single.01/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
- 100_500,
+ MissionStartupInput {
+ queue_time_ms: 100_500,
+ research_creation_gate: false,
+ },
)
.expect_err("Single.01 reaches the later Handler(43) boundary");
assert!(matches!(
@@ -14165,6 +17022,50 @@ mod tests {
#[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));
@@ -14179,6 +17080,10 @@ mod tests {
)
.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
@@ -14298,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"
@@ -14428,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");
@@ -14501,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") => {
@@ -14533,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");
@@ -14562,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();
@@ -14580,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)
@@ -14596,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: ScriptInitResolveError::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!(
@@ -14674,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");
@@ -14788,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);
@@ -14818,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);
@@ -14958,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),
@@ -14970,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],
});
}
@@ -14995,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-script/src/lib.rs b/crates/fparkan-script/src/lib.rs
index 7959ce8..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 resolver for confirmed `Init` handlers and numeric FML literals.
+//! narrow resolver for confirmed `Init` handlers and bounded FML expressions.
use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits};
use std::sync::Arc;
@@ -214,7 +214,7 @@ pub enum ScriptInitResolveError {
/// Number of preserved FML rows.
rows: usize,
},
- /// The referenced FML row is not one numeric token.
+ /// The referenced FML row is outside the supported atom-expression subset.
UnsupportedFormulaExpression {
/// Zero-based FML row index.
index: u32,
@@ -259,7 +259,7 @@ impl std::fmt::Display for ScriptInitResolveError {
),
Self::UnsupportedFormulaExpression { index, expression } => write!(
formatter,
- "formula {index} expression {:?} is not one numeric literal",
+ "formula {index} expression {:?} is outside the supported atom and binary +/− subset",
String::from_utf8_lossy(expression)
),
Self::DwordConversionOutOfRange { index, value_bits } => write!(
@@ -736,6 +736,7 @@ impl VarSet {
/// 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
///
@@ -753,10 +754,24 @@ impl VarSet {
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() {
- match self.resolve_init_instruction(instruction, input, formulas)? {
+ 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;
+ }
writes.extend(instruction_writes);
}
InitInstructionResolution::Unsupported(error) => {
@@ -781,6 +796,7 @@ impl VarSet {
instruction: &ScriptInstruction,
input: Handler19InitInput,
formulas: Option<&FormulaTable>,
+ values: &[VarSetDefault],
) -> Result<InitInstructionResolution, ScriptInitResolveError> {
match instruction.dispatch_selector() {
ScriptDispatchSelector::Handler(19) => {
@@ -842,22 +858,23 @@ impl VarSet {
));
};
let formula_index = instruction.header_words[6];
- let value = match formulas.evaluate_numeric_literal(formula_index) {
- Ok(value) => value,
- Err(FormulaLiteralError::IndexOutOfBounds { index, rows }) => {
- return Err(ScriptInitResolveError::FormulaIndexOutOfBounds {
- index,
- rows,
- });
- }
- Err(FormulaLiteralError::UnsupportedExpression { index, expression }) => {
- return Ok(InitInstructionResolution::Unsupported(
- ScriptInitResolveError::UnsupportedFormulaExpression {
- index,
- expression,
- },
- ));
- }
+ 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 {
@@ -866,6 +883,20 @@ impl VarSet {
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 => {
@@ -1357,6 +1388,113 @@ fn native_decimal_prefix_len(token: &[u8]) -> Option<usize> {
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);
@@ -1822,6 +1960,49 @@ mod tests {
};
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,
@@ -2625,12 +2806,265 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[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, integer, 0)\n").expect("numeric declaration");
+ 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");
+ FormulaTable::parse(b"FUNCTION(unsupported, target * 2, )\nFUNCTION(literal, 7.5, )\n");
let action = |formula_index| ScriptInstruction {
- header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, formula_index],
+ header_words: [u32::MAX, 1, u32::MAX, u32::MAX, 6, 0, formula_index],
references: Vec::new(),
};
let package = |formula_index| ScriptPackage {
@@ -2656,7 +3090,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
event_index: 0,
event_word: 0,
writes: vec![ScriptInitWrite {
- index: 0,
+ index: 1,
value: VarSetDefault::Dword(7),
}],
})
@@ -2668,7 +3102,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
instruction_index: 0,
error: ScriptInitResolveError::UnsupportedFormulaExpression {
index: 0,
- expression: b" target + 2".to_vec(),
+ expression: b" target * 2".to_vec(),
},
})
);
@@ -2682,7 +3116,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
instruction_index: 1,
error: ScriptInitResolveError::UnsupportedFormulaExpression {
index: 0,
- expression: b" target + 2".to_vec(),
+ expression: b" target * 2".to_vec(),
},
})
);
diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs
index e4028f9..63d21a1 100644
--- a/crates/fparkan-terrain-format/src/lib.rs
+++ b/crates/fparkan-terrain-format/src/lib.rs
@@ -58,7 +58,7 @@ pub struct TerrainMaterialLayers {
pub overlay_selector: Option<u8>,
}
-/// One `World3D` material-manager lookup key.
+/// One `World3D` material-manager phase query.
///
/// The original manager selects a WEAR table in the upper 16 bits and its
/// positional material row in the lower 16 bits.
@@ -68,6 +68,9 @@ pub struct TerrainMaterialSelection {
pub table_index: u16,
/// Positional material row in that WEAR table.
pub material_index: u16,
+ /// Animation block passed separately to GetMaterialPhase. The multitexture
+ /// path uses block 1 for Land2, with the material sampler's block-0 fallback.
+ pub animation_block_index: usize,
}
impl TerrainMaterialSelection {
@@ -99,6 +102,7 @@ impl TerrainMaterialLayers {
TerrainMaterialSelection {
table_index: 0,
material_index: u16::from(self.base_selector),
+ animation_block_index: 0,
}
}
@@ -109,27 +113,30 @@ impl TerrainMaterialLayers {
.map(|material_index| TerrainMaterialSelection {
table_index: 0,
material_index: u16::from(material_index),
+ animation_block_index: 0,
})
}
- /// Returns the detail selection corresponding to the base row in
- /// `Land2.wea`.
+ /// Returns the multitexture detail selection corresponding to the base
+ /// row in `Land2.wea`.
#[must_use]
pub fn detail_base_selection(self) -> TerrainMaterialSelection {
TerrainMaterialSelection {
table_index: 1,
material_index: u16::from(self.base_selector),
+ animation_block_index: 1,
}
}
- /// Returns the optional detail selection corresponding to the overlay row
- /// in `Land2.wea`.
+ /// Returns the optional multitexture detail selection corresponding to
+ /// the overlay row in `Land2.wea`.
#[must_use]
pub fn detail_overlay_selection(self) -> Option<TerrainMaterialSelection> {
self.overlay_selector
.map(|material_index| TerrainMaterialSelection {
table_index: 1,
material_index: u16::from(material_index),
+ animation_block_index: 1,
})
}
}
@@ -219,6 +226,8 @@ pub enum TerrainLodError {
DuplicateFaceAssignment { face: usize },
/// A source face is absent from every supported terrain slot.
MissingFaceAssignment { face: usize },
+ /// Slot bounds do not form the regular XY cell grid used by terrain rendering.
+ InvalidCellGrid,
/// A checked index calculation overflowed.
IntegerOverflow,
}
@@ -269,6 +278,7 @@ impl std::fmt::Display for TerrainLodError {
Self::MissingFaceAssignment { face } => {
write!(f, "terrain face {face} is not assigned to a supported slot")
}
+ Self::InvalidCellGrid => write!(f, "terrain slots do not form a regular XY cell grid"),
Self::IntegerOverflow => write!(f, "terrain slot index overflow"),
}
}
@@ -1948,6 +1958,7 @@ mod tests {
TerrainMaterialSelection {
table_index: 0,
material_index: 2,
+ animation_block_index: 0,
}
);
assert_eq!(
@@ -1968,11 +1979,26 @@ mod tests {
assert_eq!(
document.faces[0]
.material_layers()
+ .detail_base_selection()
+ .animation_block_index,
+ 1
+ );
+ assert_eq!(
+ document.faces[0]
+ .material_layers()
.detail_overlay_selection()
.expect("detail overlay")
.phase_key(),
0x0001_0001
);
+ assert_eq!(
+ document.faces[0]
+ .material_layers()
+ .detail_overlay_selection()
+ .unwrap()
+ .animation_block_index,
+ 1
+ );
raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
@@ -1989,6 +2015,7 @@ mod tests {
TerrainMaterialSelection {
table_index: 0,
material_index: 3,
+ animation_block_index: 0,
}
);
assert_eq!(
diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs
index 5e17a23..64e6814 100644
--- a/crates/fparkan-terrain/src/lib.rs
+++ b/crates/fparkan-terrain/src/lib.rs
@@ -6,6 +6,9 @@ use std::collections::VecDeque;
mod contour;
mod foundation;
+mod lod;
+
+pub use lod::TerrainLodPlan;
use foundation::basement_surface_mask;
pub use foundation::{
@@ -1633,6 +1636,83 @@ mod tests {
}
#[test]
+ fn lod_grid_uses_authored_face_ownership_and_pins_changed_foundation_cells() {
+ use fparkan_terrain_format::{TerrainStream, TerrainStreamAttributes};
+ let mut mesh = synthetic_land_mesh();
+ mesh.faces.extend(mesh.faces.clone());
+ assign_terrain_lod_slots(&mut mesh, &[[0, 1]], &[[0, 2], [2, 2]]);
+ mesh.slots.header_raw = vec![0; 0x8c];
+ mesh.streams.push(TerrainStream {
+ type_id: 1,
+ attributes: TerrainStreamAttributes {
+ attr1: 1,
+ attr2: 1,
+ attr3: 0,
+ },
+ size: 38,
+ });
+ for slot in &mut mesh.slots.slots_raw {
+ slot[20..24].copy_from_slice(&1.0_f32.to_le_bytes());
+ slot[24..28].copy_from_slice(&1.0_f32.to_le_bytes());
+ }
+ let mut foundation = BasementFoundation {
+ placements: Vec::new(),
+ fragments: (0..2)
+ .map(|face| BasementFoundationFragment {
+ source_face: Some(face),
+ surface_mask: mesh.faces[face].flags,
+ positions: mesh.faces[face]
+ .vertices
+ .map(|v| mesh.positions[usize::from(v)]),
+ })
+ .collect(),
+ };
+ foundation.fragments[0].positions.rotate_left(1);
+ let plan = TerrainLodPlan::new(&mesh, Some(&foundation)).unwrap();
+ assert_eq!(plan.source_face_node(3), Some(0));
+ assert_eq!(plan.source_face_node(4), None);
+ assert_eq!(
+ plan.selected_lod(0, [4.0, 4.0], 1.3),
+ Some(TerrainLod::Lod1)
+ );
+ foundation.fragments.pop();
+ let patched = TerrainLodPlan::new(&mesh, Some(&foundation)).unwrap();
+ assert_eq!(
+ patched.selected_lod(0, [4.0, 4.0], 1.3),
+ Some(TerrainLod::Lod0)
+ );
+ mesh.slots.slots_raw[0][24..28].copy_from_slice(&f32::NAN.to_le_bytes());
+ assert!(matches!(
+ TerrainLodPlan::new(&mesh, None),
+ Err(TerrainLodError::InvalidCellGrid)
+ ));
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_corpus_land_meshes_build_native_lod_grids() {
+ let mut files = 0;
+ for path in files_under(&corpus_root("IS")) {
+ if !path
+ .file_name()
+ .and_then(|s| s.to_str())
+ .is_some_and(|s| s.eq_ignore_ascii_case("Land.msh"))
+ {
+ continue;
+ }
+ let bytes = std::fs::read(&path).unwrap();
+ let nres =
+ fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
+ .unwrap();
+ let mesh = fparkan_terrain_format::decode_land_msh(&nres).unwrap();
+ let plan = TerrainLodPlan::new(&mesh, None).unwrap_or_else(|e| panic!("{path:?}: {e}"));
+ assert!(plan.has_far_geometry(), "{path:?}: no far slots");
+ files += 1;
+ }
+ assert_eq!(files, 33, "original game LOD grid count");
+ }
+
+ #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_meshes_build_surface_worlds() {
for (corpus, expected_files, expected_faces) in [
diff --git a/crates/fparkan-terrain/src/lod.rs b/crates/fparkan-terrain/src/lod.rs
new file mode 100644
index 0000000..cf5991f
--- /dev/null
+++ b/crates/fparkan-terrain/src/lod.rs
@@ -0,0 +1,301 @@
+use crate::{BasementFoundation, TerrainLod, TerrainLodError};
+use fparkan_terrain_format::LandMeshDocument;
+
+/// Camera-dependent terrain detail selection, retaining LOD0 in basement cells.
+#[derive(Clone, Debug)]
+pub struct TerrainLodPlan {
+ origin: [f32; 2],
+ inverse_cell_size: [f32; 2],
+ counts: [usize; 2],
+ forced_lod0: Vec<bool>,
+ has_lod1: Vec<bool>,
+ face_nodes: Vec<usize>,
+}
+
+impl TerrainLodPlan {
+ /// Validates the authored grid and registers foundation patches in its cells.
+ ///
+ /// # Errors
+ /// Returns an error for an invalid slot mapping, grid or foundation source face.
+ pub fn new(
+ mesh: &LandMeshDocument,
+ foundation: Option<&BasementFoundation>,
+ ) -> Result<Self, TerrainLodError> {
+ let assignments = mesh.face_lod_assignments()?;
+ let bounds = mesh
+ .nodes_raw
+ .chunks_exact(38)
+ .map(|node| {
+ let slot = u16::from_le_bytes([node[8], node[9]]);
+ let raw = mesh.slots.slots_raw.get(usize::from(slot))?;
+ let coordinate =
+ |offset: usize| f32::from_le_bytes(raw[offset..offset + 4].try_into().unwrap());
+ Some((
+ [coordinate(8), coordinate(12)],
+ [coordinate(20), coordinate(24)],
+ ))
+ })
+ .collect::<Option<Vec<_>>>()
+ .ok_or(TerrainLodError::InvalidCellGrid)?;
+ let &(first_min, first_max) = bounds.first().ok_or(TerrainLodError::InvalidCellGrid)?;
+ let header = &mesh.slots.header_raw;
+ let coordinate = |offset: usize| {
+ header
+ .get(offset..offset + 4)
+ .map(|bytes| f32::from_le_bytes(bytes.try_into().unwrap()))
+ };
+ let origin = [
+ coordinate(0).ok_or(TerrainLodError::InvalidCellGrid)?,
+ coordinate(4).ok_or(TerrainLodError::InvalidCellGrid)?,
+ ];
+ let size = [first_max[0] - first_min[0], first_max[1] - first_min[1]];
+ if !origin.iter().all(|value| value.is_finite())
+ || !size.iter().all(|value| value.is_finite() && *value > 0.0)
+ {
+ return Err(TerrainLodError::InvalidCellGrid);
+ }
+ let count_x = mesh
+ .streams
+ .iter()
+ .find(|s| s.type_id == 1)
+ .map(|s| s.attributes.attr2 as usize)
+ .ok_or(TerrainLodError::InvalidCellGrid)?;
+ if count_x == 0 || !bounds.len().is_multiple_of(count_x) {
+ return Err(TerrainLodError::InvalidCellGrid);
+ }
+ let count_y = bounds.len() / count_x;
+ let counts = [count_x, count_y];
+ for (node, &(min, max)) in bounds.iter().enumerate() {
+ let cell = [node / count_y, node % count_y];
+ for axis in 0..2 {
+ let expected = origin[axis] + cell[axis] as f32 * size[axis];
+ let tolerance = size[axis] * 1.0e-5;
+ if !min[axis].is_finite()
+ || !max[axis].is_finite()
+ || (min[axis] - expected).abs() > tolerance
+ || (max[axis] - (expected + size[axis])).abs() > tolerance
+ {
+ return Err(TerrainLodError::InvalidCellGrid);
+ }
+ }
+ }
+ let has_lod1 = mesh
+ .nodes_raw
+ .chunks_exact(38)
+ .map(|node| {
+ let slot = u16::from_le_bytes([node[10], node[11]]);
+ slot != 0xff
+ && slot != u16::MAX
+ && mesh
+ .slots
+ .slots_raw
+ .get(usize::from(slot))
+ .is_some_and(|raw| u16::from_le_bytes([raw[2], raw[3]]) != 0)
+ })
+ .collect();
+ let mut result = Self {
+ origin,
+ inverse_cell_size: size.map(f32::recip),
+ counts,
+ forced_lod0: vec![false; bounds.len()],
+ has_lod1,
+ face_nodes: assignments.iter().map(|a| a.node_index).collect(),
+ };
+ if let Some(foundation) = foundation {
+ let mut fragments = vec![0usize; mesh.faces.len()];
+ let mut unchanged = vec![false; mesh.faces.len()];
+ for fragment in &foundation.fragments {
+ if let Some(face_index) = fragment.source_face {
+ let face = mesh
+ .faces
+ .get(face_index)
+ .ok_or(TerrainLodError::MissingFaceAssignment { face: face_index })?;
+ fragments[face_index] += 1;
+ let original = face.vertices.map(|v| mesh.positions[usize::from(v)]);
+ unchanged[face_index] = fragment.positions.iter().all(|p| original.contains(p));
+ }
+ }
+ for assignment in assignments.iter().filter(|a| a.lod == TerrainLod::Lod0) {
+ if fragments[assignment.face_index] != 1 || !unchanged[assignment.face_index] {
+ result.forced_lod0[assignment.node_index] = true;
+ }
+ }
+ // Native registers patch triangles against every cell, including shared edges.
+ for fragment in foundation.fragments.iter().filter(|fragment| {
+ fragment
+ .source_face
+ .is_none_or(|face| fragments[face] != 1 || !unchanged[face])
+ }) {
+ for node in 0..bounds.len() {
+ let cell = [node / count_y, node % count_y];
+ let center = std::array::from_fn(|axis| {
+ (cell[axis] as f32 + 0.5) * size[axis] + origin[axis]
+ });
+ if triangle_intersects_cell(
+ fragment.positions,
+ center,
+ result.inverse_cell_size,
+ ) {
+ result.forced_lod0[node] = true;
+ }
+ }
+ }
+ }
+ Ok(result)
+ }
+
+ /// Returns the cell named by the authored slot that owns a source face.
+ #[must_use]
+ pub fn source_face_node(&self, face: usize) -> Option<usize> {
+ self.face_nodes.get(face).copied()
+ }
+
+ fn camera_cell(&self, point: [f32; 2]) -> Option<[i32; 2]> {
+ let cell: [f32; 2] = std::array::from_fn(|axis| {
+ ((point[axis] - self.origin[axis]) * self.inverse_cell_size[axis]).floor()
+ });
+ cell.iter()
+ .all(|v| v.is_finite() && *v >= i32::MIN as f32 && *v < i32::MAX as f32)
+ .then(|| cell.map(|v| v as i32))
+ }
+
+ /// Selects LOD1 at a squared cell distance of at least nine, unless forced near.
+ /// Returns `None` for an invalid node, camera position or FOV.
+ #[must_use]
+ pub fn selected_lod(
+ &self,
+ node: usize,
+ eye: [f32; 2],
+ horizontal_fov: f32,
+ ) -> Option<TerrainLod> {
+ let forced = *self.forced_lod0.get(node)?;
+ let has_far = *self.has_lod1.get(node)?;
+ let camera = self.camera_cell(eye)?;
+ if !horizontal_fov.is_finite() || horizontal_fov <= 0.0 {
+ return None;
+ }
+ let dx = (node / self.counts[1]) as f64 - f64::from(camera[0]);
+ let dy = (node % self.counts[1]) as f64 - f64::from(camera[1]);
+ Some(
+ if !forced && has_far && horizontal_fov >= 0.5 && dx * dx + dy * dy >= 9.0 {
+ TerrainLod::Lod1
+ } else {
+ TerrainLod::Lod0
+ },
+ )
+ }
+
+ /// Whether the map contains any usable far slots.
+ #[must_use]
+ pub fn has_far_geometry(&self) -> bool {
+ self.has_lod1.iter().any(|&value| value)
+ }
+}
+
+fn triangle_intersects_cell(
+ triangle: [[f32; 3]; 3],
+ center: [f32; 2],
+ inverse_size: [f32; 2],
+) -> bool {
+ // Normalize in f32 before testing the native [-0.5, 0.5] box.
+ let points = triangle.map(|p| {
+ std::array::from_fn::<_, 2, _>(|axis| {
+ f64::from((p[axis] - center[axis]) * inverse_size[axis])
+ })
+ });
+ let edges = std::array::from_fn::<_, 3, _>(|i| {
+ let next = points[(i + 1) % 3];
+ [next[1] - points[i][1], points[i][0] - next[0]]
+ });
+ [[1.0, 0.0], [0.0, 1.0]]
+ .into_iter()
+ .chain(edges)
+ .all(|axis| {
+ let projections = points.map(|p| p[0] * axis[0] + p[1] * axis[1]);
+ let radius = 0.5 * (axis[0].abs() + axis[1].abs());
+ projections.iter().copied().fold(f64::INFINITY, f64::min) <= radius
+ && projections
+ .iter()
+ .copied()
+ .fold(f64::NEG_INFINITY, f64::max)
+ >= -radius
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn patch_registration_keeps_native_normalization_and_triangle_edges() {
+ let size = f32::from_bits(0x42d4_15f5);
+ let center = [2.5 * size, 6.5 * size];
+ let edge = [
+ [3.0 * size, 6.0 * size, 0.0],
+ [3.0 * size, 7.0 * size, 0.0],
+ [4.0 * size, 6.0 * size, 0.0],
+ ];
+ // The authored boundary rounds outside this cell after native normalization.
+ assert!(!triangle_intersects_cell(edge, center, [size.recip(); 2]));
+ assert!(triangle_intersects_cell(
+ edge,
+ [3.5 * size, 6.5 * size],
+ [size.recip(); 2]
+ ));
+ assert!(!triangle_intersects_cell(
+ [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]],
+ [1.75, 1.75],
+ [2.0; 2]
+ ));
+ assert!(triangle_intersects_cell(
+ [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]],
+ [1.5, 1.0],
+ [2.0; 2]
+ ));
+ }
+
+ #[test]
+ fn native_cell_distance_boundary_zoom_and_basement_override() {
+ let mut plan = TerrainLodPlan {
+ origin: [0.0; 2],
+ inverse_cell_size: [1.0; 2],
+ counts: [8, 8],
+ forced_lod0: vec![false; 64],
+ has_lod1: vec![true; 64],
+ face_nodes: Vec::new(),
+ };
+ assert_eq!(
+ plan.selected_lod(2 * 8 + 2, [0.9, 0.9], 1.3),
+ Some(TerrainLod::Lod0)
+ );
+ assert_eq!(
+ plan.selected_lod(3 * 8, [0.9, 0.9], 1.3),
+ Some(TerrainLod::Lod1)
+ );
+ assert_eq!(
+ plan.selected_lod(3 * 8, [1.0, 0.9], 1.3),
+ Some(TerrainLod::Lod0)
+ );
+ assert_eq!(
+ plan.selected_lod(3 * 8, [0.9, 0.9], 0.49),
+ Some(TerrainLod::Lod0)
+ );
+ assert_eq!(
+ plan.selected_lod(3 * 8, [0.9, 0.9], 0.5),
+ Some(TerrainLod::Lod1)
+ );
+ plan.forced_lod0[3 * 8] = true;
+ assert_eq!(
+ plan.selected_lod(3 * 8, [0.9, 0.9], 1.3),
+ Some(TerrainLod::Lod0)
+ );
+ plan.has_lod1[7 * 8] = false;
+ assert_eq!(
+ plan.selected_lod(7 * 8, [0.9, 0.9], 1.3),
+ Some(TerrainLod::Lod0)
+ );
+ assert_eq!(plan.selected_lod(64, [0.0; 2], 1.3), None);
+ assert_eq!(plan.selected_lod(0, [f32::NAN, 0.0], 1.3), None);
+ assert_eq!(plan.selected_lod(0, [0.0; 2], f32::INFINITY), None);
+ }
+}
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index 76ddaaa..0df1e5a 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -10,6 +10,11 @@ authoring API без динамического подтверждения.
Не доказаны символами точные имена renderer vtable slots, полный набор CShade
state transitions и окончательный порядок части transparent/FX/shadow subpasses.
+Дистанционная кривая тумана по квадратам границ подтверждена в живом CShade.
+Обычный GPU fog color из горизонта подтверждён исполнением; выбор проходов по
+native shade-state и их временные изменения цвета пока заменены общим туманом мира;
+это приближение считается как `render_coverage fog_pass_eligibility_approx`.
+
После ограничения цели рельефом интерактивная камера проверяет движение
против каждого отрисовываемого корневого строения класса `FORT` и его
отрисовываемых компонентов. Для этого пути используются текущая Node38-поза
@@ -45,26 +50,24 @@ MSH-запрос со свободной preview-камерой; это не пÐ
стратегической и следящей камерами не подтверждено
([MSH](../reference/msh.md)).
-Для MSH без потока цветов type 16 подтверждён только один постоянный alpha
-override для диапазонов с CPU-цветом и raw-флагом `Batch20` `0x0008`: GOG
-`Terrain.dll`, SHA-256
-`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`, RVA
-`0x1B0F0`, задаёт alpha `0`, если установлен bit `0x0040` и сброшен `0x0010`.
-Это покрывает `0x0148`; `0x0108`, `0x0118` и `0x0058` сохраняют alpha MAT0,
-причём в `0x0058` bit `0x0010` имеет приоритет. Поток type 16 или отсутствие
-`0x0008` исключают override. Проверка заводской сцены с фиксированной нативной
-камерой подтвердила на одном кадре входа восстановление X-образного рисунка
-двери, который виден и в оригинале; прежний Vulkan-readback показывал на этом
-месте сплошной чёрный прямоугольник. Сравнение выполнено при времени сцены
-2000 мс и трёх кадрах, запуск с Vulkan validation завершился без предупреждений
-и ошибок. Это подтверждает только структуру проёма в этой сцене: правильность
-освещения и полный попиксельный паритет не установлены.
-
-Общее fade-поведение по расстоянию в preview не реализовано. Открыты точная
-метрика расстояния, выбор первого CPU-принятого треугольника диапазона и
-эквивалентность frustum/culling-отбора оригинала; эти вопросы нельзя вывести из
-захвата заводской двери. Данные потока type 16 ещё не декодированы, поэтому
-правило выше относится только к MSH-диапазонам без этого потока цветов.
+Кривые alpha и метрика расстояния порталов реализованы для MSH без type 16
+([описание кадра](../reference/render-frame.md#прозрачность-порталов-msh)). Native CPU clipping может выбрать другую первую вершину;
+это приближение явно считается как `portal_fade_packet_selection_approx`.
+
+Данные type 16 ещё не декодированы. Внутренний ракурс «Конструктора» после
+настоящего входа игрока подтверждает исчезновение чёрной поверхности в проходе;
+наружные окна обеих миссий сверены на расстояниях около 60/85/110, а ближние
+окна — с внутренней стороны. Окно «Конструктора» на правом краю кадра при
+дистанции около 85 совпадает; внутренние дальние ракурсы и остальные случаи
+отсечения ещё не приняты. Общей готовности R2 эти пары не доказывают.
+
+Старые захваты без `geometry_projection` используют состояние камеры после
+обхода мира, которое может отсекать близкие поверхности. Это отмечается
+`captured_projection_unverified`; для приёмки таких поверхностей нужен новый захват.
+
+Захваты без `sky_uv_clock` не подтверждают начальное положение облаков,
+звёзд и фона. Replay использует elapsed clock приложения и считает
+это как `captured_sky_phase_unverified`; для приёмки неба нужен новый захват.
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
@@ -72,6 +75,14 @@ log на минимальных сценах с одним типом матер
## FXID field-level semantics
+Control Op1 пока использует цвет и радиус без случайных добавок команды;
+это явное приближение считается в `render_coverage control_op1_jitter_unsupported`.
+Масштаб радиуса длиной преобразованного направления и нижняя замена 0,01 восстановлены.
+
+Control Op2 воспроизводит modes 0/2; modes 1/3, body+76 и неподдержанные
+time modes/привязки считаются как `render_coverage control_op2_unsupported`.
+Управление фазой экземпляра командами Control остаётся до его подключения в runtime.
+
Размеры команд, resource references, lifecycle, flags families и используемые
time modes известны. Не закрыто значение каждого поля body opcodes 1--10,
отсутствующий во всех проверенных каталогах opcode 6 и точные формулы редких
@@ -133,7 +144,7 @@ control word процесса.
Лицензированный AutoDemo по-прежнему загружается с raw TMA anchor-ами
`[(500.2857, 751.8217), (728.0, 449.0)]` и записями `[(500, 752, 0),
(728, 449, 1)]`. Rust теперь также читает optional `.fml` sibling для каждого
-script base и поддерживает numeric literal assignments в первом `Init`.
+script base и поддерживает узкий набор атомарных формул в первом `Init`.
Отсутствующий `.fml` не мешает пакету с одними `Handler(19)`; другие ошибки
чтения распространяются как VFS errors.
@@ -144,9 +155,11 @@ script base и поддерживает numeric literal assignments в первÐ
два доказанных действия: `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]`. Маппинг подтверждён
+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`;
@@ -154,23 +167,58 @@ reference; оба равны `0xffffffff` в поддержанной форме
Formula table загружается из `<bundle-base>.fml`; native `FUN_100123b0`
(RVA `0x123b0`) проверяет ordinal, compiler `FUN_100152a0` (RVA `0x152a0`)
-строит numeric literal node, а evaluator `FUN_10015b30` (RVA `0x15b30`)
-возвращает его raw `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` с
+строит числовые, переменные и операторные узлы, а 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 остаётся узкой: ссылка должна вести на один digit-starting numeric
-token и типизированную `float`/`DWORD` ячейку. Неизвестные expression nodes,
-переменные, unary operators, составные выражения и source-copy ветвь
-`source != 0xffffffff` остаются явными ошибками; native source-copy переносит
-всю varset record, не только числовое значение. В preview package сохраняется,
-но `Unsupported` не применяет частичные writes; полная загрузка отклоняет весь
-новый мир и сохраняет предыдущие world/RNG. Это политика Rust, не правило
-оригинального dispatcher-а.
+Граница 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; это не проход
@@ -184,6 +232,34 @@ core и `ResearchCandidateSet::refresh_selection`: последний получ
создаёт владельца живого контекста, не передаёт реальные 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`) одна ветвь сначала проходит записи
кланов и вызывает для них `FUN_100392e0`, затем вызывает `FUN_10039440` (RVA
@@ -262,6 +338,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
@@ -405,7 +501,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 и расчётный диапазон произведения/отношения за
@@ -449,6 +547,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` в текущее и
@@ -512,12 +645,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` и
@@ -655,16 +824,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` не имеет доказанных единиц
@@ -819,6 +991,46 @@ arg1 следующего вызова. `AddNewObjectToGame` принимает
`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,
diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md
index be8c8b3..f7f8b97 100644
--- a/docs/appendices/script-vm.md
+++ b/docs/appendices/script-vm.md
@@ -22,8 +22,8 @@ symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaul
`fparkan-script::parse_varset` уже читает подтверждённые numeric
`VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются
opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и
-general `FUNCTION(...)` evaluation остаётся вне numeric contract, но принятые
-строки сохраняются для доказанных literal assignments первого `Init`;
+general `FUNCTION(...)` evaluation остаются вне восстановленного контракта,
+но принятые строки сохраняются для ограниченного поднабора формул первого `Init`;
GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration:
31 `float` и 200 `DWORD` (от `f0` до `fY`);
loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при
@@ -424,10 +424,13 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
Ограниченный Rust-исполнитель `VarSet::resolve_init` ищет первое событие
`Init` и проходит его инструкции по порядку. Он возвращает записи для
-поддержанного `Handler(19)` и числовых literal assignments; прочая форма
-останавливает preview на `Unsupported`. Runtime применяет упорядоченные записи
-к отдельной копии varset выбранного клана лишь после успешного прохода всего
-события. Отсутствующий `Init` даёт
+поддержанного `Handler(19)` и формул с одним числовым или переменным атомом либо
+с двумя такими атомами, соединёнными одним бинарным `+` или `-`; прочая форма
+останавливает preview на `Unsupported`. Runtime начинает вычисление с копии
+defaults и записывает в неё каждый успешно разрешённый шаг, поэтому поздняя
+формула видит прежние записи этого события. Упорядоченные записи попадают в
+состояние клана только после успешного прохода всего `Init`. Отсутствующий
+`Init` даёт
статус `Missing` и не создаёт записей инициализации; пустой первый `Init` даёт
`Executed` без записей. Если форма инструкции пока не поддержана, package
остаётся доступен в preview режиме со статусом `Unsupported`, а default-значения
@@ -437,7 +440,7 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
Это правила ограниченного Rust-исполнителя, а не обнаруженное поведение
оригинального dispatcher-а.
-#### Числовые литералы `.fml` в `Init`
+#### Литералы и простые формулы `.fml` в `Init`
В той же сборке `ai.dll` (`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`)
загрузчик `FUN_10001000` открывает `.fml` рядом с базой каждого `.scr`.
@@ -455,16 +458,35 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
остаётся ошибкой VFS.
Native selector `-1` при source `-1` и нулевой глубине условий выбирает
-formula assignment; в native record `header_words[1]` — индекс целевой varset
-ячейки, а `header_words[6]` — индекс formula row. `FUN_100123b0` (RVA `0x123b0`) проверяет индекс строки,
-`FUN_10015b30` (RVA `0x15b30`) возвращает результат единственного literal node
-kind `3`, а `FUN_10013650` передаёт его типизированной ячейке через
-`FUN_10012c00` (RVA `0x12c00`). Компилятор `FUN_100152a0` получает числовой
-prefix как `double` через `FUN_10020af0` (RVA `0x20af0`), затем записывает
-его как `float` в node `+0x10`. Rust ограничивает эту ветвь одним
-digit-starting числовым токеном с необязательным хвостом пробельных байтов;
-ведущие unary `+`/`-`, variable references, операторы и другие node kinds
-остаются явными `UnsupportedExpression`.
+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
@@ -477,8 +499,8 @@ digit-starting числовым токеном с необязательным Ñ
исходные `f32` bits; `DWORD` (tag `5`) проходит через `__ftol` (RVA `0x1df70`)
с усечением к нулю и записывает младший DWORD. Поэтому literal `0.8` остаётся
`0x3f4ccccd` в `float`, но записывается как `0` в `DWORD`; `100.0` даёт raw
-DWORD `100`. Неизвестные выражения не заменяются нулём, а запись всего первого
-`Init` применяется только после успешного прохода.
+DWORD `100`. Неизвестные выражения не заменяются нулём, а записи первого `Init`
+публикуются только после успешного прохода.
Native x86 oracle на GOG `ai.dll` SHA-256
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` загрузил
@@ -500,6 +522,27 @@ Native x86 oracle на GOG `ai.dll` SHA-256
| 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 после этих присваиваний; у обычной
@@ -511,6 +554,17 @@ Rust сохраняет всю таблицу строк, но вычисляеÑ
не перечисляют 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
@@ -760,8 +814,52 @@ tie-векторов подтверждает реакцию `FISTP`, но не
два слова и третье исходное слово, выдаёт три типизированные записи и отклоняет
отсутствующие, выходящие за диапазон или не-`DWORD` цели. Общий для первого
события метод `VarSet::resolve_init` добавляет этот `Handler(19)` и формулу из
-одного numeric literal; остальные формы инструкции и содержимое `.scr`
-сохраняются reader-ом, но остаются вне этого исполнения.
+одного числового или переменного атома либо двух таких атомов с одним бинарным
+`+` или `-`. Остальные формы инструкции и содержимое `.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
@@ -1060,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
@@ -1247,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`). Последний путь не является
@@ -1314,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`)
@@ -1321,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/reference/atmosphere.md b/docs/reference/atmosphere.md
index 65ed176..dba20c2 100644
--- a/docs/reference/atmosphere.md
+++ b/docs/reference/atmosphere.md
@@ -50,7 +50,25 @@ key_time = floor(duration * (key_hour * 60 + key_minute) * 60 / 86400)
theta = (clamp((now - birth_start) / lifetime, 0, 1) * 1.2 - 0.1) * pi
```
-Затем к нему применяется матрица рождения. У солнца и луны независимые
+Затем к нему применяется матрица рождения как к вектору-столбцу. Если `A` и
+`B` — углы рождения, а `x = cos(theta)` и `z = -sin(theta)`, результат равен
+
+```text
+[x*cos(A) + z*sin(A)*sin(B),
+ x*sin(A) - z*cos(A)*sin(B),
+ z*cos(B)]
+```
+
+Порядок важен для всего освещения: транспонированное умножение переносит
+солнце на другую сторону мира и меняет освещённые склоны, блики и тени.
+В GOG `Terrain.dll` (SHA-256
+`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`)
+матрицу строит RVA `0x4A550`, а вектор преобразует `CSun::Update`, RVA
+`0x4B570`. Живой замер после 282184 мс при длительности 525000 мс и углах
+`[90, 30]` дал направление `[-0.4950135, -0.1408745, -0.8573891]`;
+Rust-тест проверяет этот результат с допуском `3e-6`.
+
+У солнца и луны независимые
интервалы и независимые направления: при отсутствии активной луны нельзя
подменять её направление противоположностью солнца. `SunFrame::color` — это
единственный primary directional RGB. Второй источник получает направление
@@ -131,6 +149,31 @@ gradient и clouds row 2. Для nebula/stars/clouds используются UV
directional RGB из `sky.packed[0]`. Это четыре фиксированных pass, а не три
независимых цветных слоя.
+Все три текстурных слоя движутся. Обновление GOG `CSky`
+(`Terrain.dll`, RVA `0x48B90`, запись UV `0x4942A–0x495B7`;
+[сборка и SHA-256](original-binaries.md)) берёт разность текущего `u32` clock
+и сохранённого поля объекта `+8` с переполнением. Это отдельные миллисекунды
+жизни неба, а не абсолютная фаза суток из SKE.
+
+| Слой | Период | Добавка к исходным UV |
+|---|---:|---|
+| Фон | 300 000 мс | одинаковый сдвиг U и V |
+| Звёзды | 100 000 мс | одинаковый сдвиг U и V |
+| Облака | 50 000 мс | сдвиг только U |
+
+Добавка равна остатку от деления elapsed clock на период, умноженному на
+нативный коэффициент `f32` (`3.3333333e-6`, `1e-5`, `2e-5`). Вершинные UV
+каждый раз вычисляются от позиции купола заново; накопление приращений
+создавало бы дрейф. `SkyMesh::update_uvs` получает текущие миллисекунды кадра
+и сохраняет геометрию купола. В работающем оригинале проверены UV всех трёх
+слоёв, включая разность clock с переполнением.
+
+Захват сохраняет `sky_uv_clock` на входе в рендер неба, проверяя его по
+фактическим UV вершины зенита и clock того же кадра. Replay удерживает эту
+фазу вместе с фазой суток. Без такого поля текстуры движутся по elapsed clock
+приложения; начальная фаза оригинала неизвестна и явно считается как
+`captured_sky_phase_unverified`.
+
Цвет первого screen-gradient и последнего пояса вычисляет
`SkyFrame::screen_gradient(yaw_radians, glare_rgb_delta)`. Native `CSky` сначала
округляет `(yaw + pi) * 180 / pi` режимом nearest-even, выбирает сектор
@@ -167,21 +210,27 @@ RGB palette 12 с минимумом `80/255` на канал и alpha `150/255`
## Туман и свет
-Нативное обновление `CSky` записывает две дальности и один packed RGB.
+Нативное обновление `CSky` записывает две дальности и нижнюю границу освещения.
`FogFrame` повторяет этот контракт:
```text
fog.start = sky.values[0] * 700
fog.end = sky.values[1] * 700
-fog.color = RGB(sky.packed[1]) / 255
+lighting_floor = RGB(sky.packed[1]) / 255
```
Поля не скрывают порядок исходных параметров: значения сохраняются в
-`SkySample`, а преобразование выполняется только в `fog_frame`. Vulkan path
-передаёт цвет и дальности в frame uniforms; vertex shader вычисляет линейный
-коэффициент по расстоянию до камеры, а fragment shader смешивает RGB материала
-с fog color в ветвях combiner, которые используют туман. Конкретный draw state
-может обходить это смешивание.
+`SkySample`, а преобразование выполняется только в `fog_frame`. Нижняя граница
+освещения ограничивает результат расчёта света снизу; она не задаёт цвет тумана.
+Обычный цвет тумана выбирается из `sky.colors[0..4]` по направлению камеры,
+как цвет горизонта. Это подтверждено в `CSky::Render` и живым совпадением
+его значения с `D3DRENDERSTATE_FOGCOLOR` в том же игровом тике.
+
+Vulkan path передаёт эти параметры раздельно. Vertex shader вычисляет
+коэффициент по квадратам расстояния и границ, а fragment shader смешивает
+RGB материала с цветом тумана. Конкретный native draw state может обходить
+смешивание или временно менять его цвет, например на чёрный для отдельного
+прохода. Полный выбор таких проходов ещё не воспроизведён.
Цвет primary света солнца — RGB palette 12, нормированный делением на 255 и
умноженный на trailing parameter 2. `SunFrame::direction` — нормированный
diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md
index 799a969..f3ecfb4 100644
--- a/docs/reference/environment-effects.md
+++ b/docs/reference/environment-effects.md
@@ -218,6 +218,41 @@ OneShot -> воспроизвести разовый звук молни
отдельный spatial source, применяет DirectSound range/pan и текущий listener.
Отсутствие устройства вывода не должно останавливать симуляцию.
+### Звуки Control
+
+Команда Op2 может принадлежать не погоде, а экземпляру эффекта на узле модели.
+`fparkan-fx::ambient_sound` хранит её временное окно и предыдущее значение
+фазы. В режиме 0 разовый звук начинается при строгом пересечении начала окна:
+`previous <= start && start < phase`, если фаза движется вперёд. Значение
+`phase == 1` для этой проверки заменяется нулём и подготавливает следующий
+цикл. Режим 2 включает loop на обеих границах окна; внутри окна уже работающий
+источник обновляется, а не запускается заново. Выход за окно останавливает его.
+
+Позиция интерполируется между двумя локальными точками и проходит через ту же
+матрицу владельца, что и графические команды. Минимальная и максимальная
+дистанции остаются постоянными; частота интерполируется по фазе окна.
+Каждая команда получает отдельный источник звука, даже если ресурс совпадает
+с ресурсом соседней команды. Дождь сохраняет свой loop. Перемещение слушателя,
+потеря фокуса и завершение приложения затрагивают все эти источники.
+
+Эти ветви восстановлены в GOG `Effect.dll` (сборка из
+[таблицы бинарников](original-binaries.md)): init RVA `0x12D10`, update
+`0x12EB0`. Живой loop `sounds.lib:f_defl.wav` передаёт в звуковой backend
+диапазон `12/80`, частоту `1` и мировую позицию из матрицы владельца.
+В режиме 2 признак loop берётся из первого DWORD body, а не из command flag.
+
+Обычное создание эффекта Control сохраняет time mode заголовка и задаёт
+постоянную фазу нулём. Raw selector строки служит ID экземпляра, а не номером
+режима времени. Поэтому `f_comp_sfx` с time mode 0 и началом окна `0.001`
+остаётся тихим до управляющей команды Control. Preview такие команды ещё не
+исполняет. Периодические звуки с time mode 2 уже следуют общим часам и текущей
+позе узла. `render_coverage control_op2_supported` считает подготовленные
+команды, а `control_sound_start/stop` показывает фактические переходы.
+
+Неизвестные backend modes 1/3, ненулевое поле body+76, неподдержанное время
+или привязка владельца пропускаются с `render_coverage control_op2_unsupported`.
+Они не заменяются разовым звуком или произвольным loop.
+
## Цвет солнца
Солнце берёт цвет спрайта и прозрачность бликов из `palette[10]` расписания
@@ -236,11 +271,19 @@ packed: [key.colors[10], key.colors[14]],
`AtmosphereFrame` пока не несёт этот бит обновления, поэтому sampler
выдаёт интерполированный цвет при каждом запросе.
-## Экранная геометрия солнца и луны
+## Отрисовка солнца, луны и бликов
+
+`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, поэтому более близкая поверхность
+может закрыть спрайт, а сам спрайт не меняет глубину для последующих слоёв.
-`CSun::Render` сначала переводит направление небесного тела в пиксели текущего
-viewport, затем собирает quad с native XYZRHW-позицией. При положительной высоте
-viewport Vulkan пиксели переводятся в clip-space так:
+В Vulkan ряды 3 и 4 сохраняют этот contract отдельным bit 8 material mode.
+CPU каждый кадр преобразует viewport-пиксели к clip space текущего размера
+swapchain:
```rust
position = [
@@ -250,13 +293,17 @@ position = [
];
```
-Vertex shader пропускает `clip_from_world` для строк солнца и луны. CPU каждый
-кадр пересчитывает центр и размер quad по текущему viewport; старые пиксельные
-координаты не переносятся на новый размер. World-space billboards бликов
-остаются отдельным путём. Материал экранного quad не освещается, vertex fog
-отключён, а sky far-depth bit к нему не применяется.
+Положительная высота Vulkan viewport отображает эти координаты в тот же
+top-down экран. Vertex shader пропускает мировую матрицу только для этого
+режима:
+
+```glsl
+vec4 clip_position = screen_tl
+ ? vec4(in_position, 1.0)
+ : frame.clip_from_world * vec4(in_position, 1.0);
+```
-Для мировой геометрии
+Материал остаётся unlit, fog factor равен нулю. Для мировой геометрии
оригинал задаёт viewport `MinZ = 0.1`, `MaxZ = 0.99`: после отсечения
проекционная глубина отображается в этот диапазон:
@@ -280,35 +327,39 @@ RVA `0x7170`, участок `0x71E7–0x723C`, копирует MinZ/MaxZ в re
[таблице бинарников](original-binaries.md). Нативные значения этих двух полей
имеют биты `0x3DCCCCCD` и `0x3F7D70A4`.
-
-## Геометрия визуальных FX-команд
-
-`fparkan-fx::fx_visual` формирует кадры для двух подтверждённых вариантов
-`f_gener_ball`. Op4 принимает mode 0 с положительным знаменателем в `body+0xC4`
-и выдаёт camera-facing quad; расстояние камеры считается через native
-component-wise projection родительской матрицы. Op9 принимает mode 2:
-селектор `body+0xC4` со значением 0, 1 или 2 отображается в native flag base
-3, 5 или 6 и выбирает одну из трёх сеток; категория render flags выбирает её
-детализацию. `body+0xC8` выбирает UV path:
-нулевое значение проецирует позицию вершины на XY, ненулевое использует
-фиксированные координаты quad и cap. Кадр сохраняет MAT0 resource, фазу,
-opacity, вершины и triangle indices для renderer.
-
-Evaluator получает готовые command body, phase, camera point и внешнюю
-матрицу. Выбор Control row, manager gates, owner attachment, header jitter и
-global RNG остаются задачей владельца. `fparkan-game` пока использует для Op9
-категорию 0 с наибольшей детализацией; выбор категории по projected size ещё
-не реализован.
-
-Для Op3 поддерживаются body-типы 0, 1 и 2. Поля отсчитываются от
-`FxCommand.raw_body`, то есть после 4-байтового command word. Типы 0 и 1
-используют общие position/scale/phase/opacity tracks и camera-facing основу;
-тип 1 и тип 2 также интерполируют ось, а тип 2 строит basis через native
-D110-порядок. Все три evaluator-а принимают уже собранную матрицу владельца и
-не выбирают Control row или attachment. Текущий путь для типов 0 и 1
-предполагает native render context 0: `Effect.dll` проверяет общий gate
-`context + 0x60 < 8`, а API этих evaluator-ов не выбирает другой context.
-Evaluator типа 2 принимает индекс context явно и учитывает этот gate в `active`.
+Sky-background bit 4 продолжает помещать слои на дальнюю плоскость. Отдельные
+flare rows 5 и 6 пока используют world-space billboard builder: для них не
+подтверждён этот же native TL путь.
+
+## Эффекты Control
+
+Погодное расписание `sky.ske` — только один источник эффектов. У объектов
+миссии таблица Control (`.ctl`) связывает строки с FXID-командами. Текущий
+preview выбирает начальную группу Control и исполняет только строки без
+predicate, chain и state gate. Условия игровых событий и переходы между
+группами принадлежат полному игровому Control runtime и здесь не моделируются.
+
+Type 4 row задаёт локальную basis эффекта через три точки CTL point list. Для
+валидной привязки первая точка также задаёт owner node. Путь MSH выбирает позу
+этого узла из текущей pose hierarchy; для внешнего mounted-компонента к ней
+добавляется поза socket. После этого применяются object transform, header
+scale и локальная матрица FX. Подробнее о порядке матриц см.
+[описание MSH](msh.md#control-fx-и-owner-transform).
+
+Renderer preview поддерживает FXID-команды Op3 modes 0–2, Op4, Op7, Op8 и Op9.
+Op3 mode0 строит camera-facing поверхность из анимируемых локальных позиции и
+масштаба; Op7/Op8 обновляют отдельные элементы particle pool; Op9 создаёт
+индексированную полусферическую сетку. Op1 вычисляется отдельно как список
+point lights: selector 1 и записи без `flags & 0x80000000` входят в общий
+diffuse light set. Записи с этим битом сохраняют native flags для специальных
+путей и projected shadows, но не добавляются в общий diffuse список.
+
+Body discriminator Op3 читается из первого DWORD `FxCommand.raw_body`; offsets
+в evaluator-е относительны к данным после четырёхбайтового command word. Mode 1
+добавляет анимируемую ось к camera-facing basis, mode 2 использует native
+D110 basis. Для modes 0 и 1 preview предполагает render context 0, который
+проходит общий gate `Effect.dll` `0x106C0`; их API не выбирает другой context.
+Evaluator mode 2 принимает индекс context и учитывает gate при расчёте `active`.
У Op3 mode 1 ось задаёт длину прямоугольника, а не его нормаль. Для
локального вектора к камере `v` и анимируемой оси `a` столбцы базиса имеют
@@ -359,27 +410,66 @@ RVA `0x93D0`, ветка mode 1 с `0x9739`, сборка столбцов `0x97
семена отдельных экземпляров лишь детерминированно заменяют process-global
состояние и не воспроизводят общий порядок вызовов.
+`f_gener_ball` evaluator покрывает Op4 mode 0 с положительным знаменателем в
+`body+0xC4` и Op9 mode 2. Для Op9 selectors `body+0xC4` со значениями 0, 1 и 2
+отображаются в native flag bases 3, 5 и 6. Нулевой `body+0xC8` проецирует UV на
+XY, ненулевой выбирает статические quad/cap координаты. Op4 вычисляет локальное
+расстояние камеры тем же component-wise projection, что использует native path.
+
+FX-команды используют общий preview clock и ограниченный набор time modes:
+mode 0 оставляет фазу равной нулю, mode 1 вычисляет `elapsed / duration` без
+зажатия, mode 2 повторяет цикл. Header flags `0x200` и `0x20` преобразуют эту
+фазу; command path `0x8120` зажимает результат только после преобразований.
+Effect renderer проверяет header bit `0x800` только при
+render selector 0; native AniMesh component path был захвачен с selector 1,
+поэтому preview использует selector 1 и не подавляет такие visual commands.
+Этот capture также показал `0xD00` FX entries в 53-entry manager. Динамическое
+состояние manager `A8`, связанное с header gate и фактической FX phase, пока не
+моделируется. Header bit `0x10` относится к activation lifecycle и сам по себе
+не отключает draw.
+Header jitter и random shift хранятся один раз на Control row и совместно
+применяются к его sibling-командам; отдельный детерминированный поток каждой
+строки сохраняет эту связь, но не повторяет порядок глобального native RNG.
+Неразобранные группы, event predicates и неразрешённые owner attachments
+пропускаются.
+
+Для visual FX material берётся из resource reference команды. MAT0 выбирает
+фазу по normalized FX phase, независимо от wall-clock анимации world materials.
+Command opacity передаётся vertex alpha, а FX MAT0 alpha не умножает её второй
+раз; texture alpha продолжает участвовать в shader. Текущая preview-ветка
+использует SourceAlpha и read-only depth state и допускает только MAT0-фазы,
+проходящие локальный predicate additive-RGB approximation. Семантика native
+`flag40` и directional-light ветки остаётся открытой.
+
+Generated Op9 mesh разделяется на отдельный draw range для каждого native
+quad и cap. Каждый range получает ключ по камере до первой индексированной
+вершины, в порядке убывания расстояния. Равные ключи используют midpoint
+insertion из native queue, поэтому их порядок не является стабильным по
+первоначальному времени поступления. Preview использует category 0 как
+highest-detail mesh; projected-size LOD и полное визуальное совпадение размера
+и формы пока не реализованы.
+
## Частицы Op8
`fparkan-fx::smoke` разбирает body Op8, хранит кольцевой пул команды и выдаёт
quad-ы с ориентацией к камере для активных слотов. Нативная нормализованная
-фаза окна ограничивается диапазоном 0–1 и задаёт интервал проверки следующего события;
-сравнение строгое (`schedule_base + interval < update_clock`). Все события
-catch-up в одном обновлении проверяются по тому же нормализованному интервалу;
-после события нативный код продвигает базу расписания интервалом, вычисленным
-по сырой фазе эффекта, а возраст слота считает по нормализованному интервалу.
-Когда фаза равна единственной границе окна, деление `0/0` даёт NaN, который
-нативный unordered-путь сравнения ограничивает нулём.
+фаза окна ограничивается диапазоном 0–1 и задаёт интервал проверки следующего
+события; сравнение строгое (`schedule_base + interval < update_clock`). Все
+события catch-up в одном обновлении проверяются по тому же нормализованному
+интервалу; после события нативный код продвигает базу расписания интервалом,
+вычисленным по сырой фазе эффекта, а возраст слота считает по нормализованному
+интервалу. Когда фаза равна единственной границе окна, деление `0/0` даёт NaN,
+который нативный unordered-путь сравнения ограничивает нулём.
Нулевая база расписания служит признаком начального состояния: обновление
сохраняет текущую опору эмиттера и базу времени, не создавая частицу. Если
первое время равно нулю, следующее обновление повторно захватывает опору. При
событии скорость опоры считается от сохранённой позиции до текущей за время
от базы расписания; слот смещает сохранённую опору на `скорость * due_interval`.
-Обновление без событий не переносит сохранённую опору к текущему владельцу.
-Откат часов корректирует только базу расписания до текущего времени: слоты,
-опора и три потока случайных
-чисел не сбрасываются.
+При нулевой скорости оригинал подставляет ось X владельца и для направления,
+и для этого смещения. Обновление без событий не переносит сохранённую опору к
+текущему владельцу. Откат часов корректирует только базу расписания до текущего
+времени: слоты, опора и три потока случайных чисел не сбрасываются.
Evaluator принимает отдельные начальные состояния для эмиттера, знаковых
отклонений и материала. Переход каждого потока совпадает с общим native LFSR,
diff --git a/docs/reference/msh.md b/docs/reference/msh.md
index 0a8a2cb..130a203 100644
--- a/docs/reference/msh.md
+++ b/docs/reference/msh.md
@@ -95,6 +95,51 @@ translation суммируется, а rotations умножаются; послÐ
запускает игровой Control/AI и не обещает полного совпадения с runtime или
всеми x87 rounding modes.
+## Control clock и pose компонентов
+
+Статический Vulkan preview начинает с позы, вычисленной из сохранённых `.ctl`
+bindings. Пока не задан `--static-animation-frame`, при каждом новом сэмпле
+сцены приложение сэмплирует поддержанные tag 21 записи для каждого экземпляра
+компонента и заново строит его MSH node hierarchy; time и transition используют
+общий runtime clock. Динамический sampler принимает tag 21 mode `0`,
+controller flags `5` (raw value `0xFFFFFFFF` трактуется как `5`),
+разрешённый gate provider и static row mapping: оба
+variable index равны `0xFFFFFFFF`, previous-row flag не установлен, transition
+rate конечен и положителен.
+
+Fallback к статической CTLD-позе всего экземпляра компонента срабатывает только
+при следующих ошибках sampler: `UnsupportedTag21Mode`,
+`UnsupportedVariableMapping`, `MissingGateProvider`,
+`GateProviderKeyOutOfRange`, `MissingGateProviderValue` и
+`GateProviderCountMismatch`. Он заменяет весь динамический результат экземпляра,
+а не одну запись. Остальные sampling errors завершают обновление сцены; например,
+`UnsupportedPreviousRowMapping` и `InvalidTransitionRate` не включают fallback.
+
+`--control-clock-ms <u32>` воспроизводит общий MSH/FX clock линейно: первый из
+`--frames` успешно представленных кадров получает `0`, последний — заданное
+время. Replay clock привязан к числу представленных кадров: повторная подготовка
+того же кадра, включая swapchain recreation, использует прежний sample.
+`--static-animation-frame <u16>` отключает динамическую MSH выборку и задаёт один
+frame всем компонентам. Этот override не включает AI и не превращает preview
+в полный игровой Control runtime.
+
+## Control FX и owner transform
+
+Для выбранной Control effect group runtime берёт строки без predicate, chain и
+state gate. Type 4 row offsets выбирают три точки из CTL point list; позиции
+точек задают локальное начало, а их направления — локальную basis. К basis
+применяются FX header scale и локальные random shifts. Затем матрица проходит
+через mission rotation/translation и object scale, optional mounted-component
+pose, owner-node pose и локальную матрицу эффекта. Поэтому FX mounted-компонента
+следует её текущему MSH owner pose, а не остаётся привязанным к mission root.
+
+Когда point record задаёт допустимый owner node, его pose разрешается из MSH
+hierarchy. Для mounted external components node pose вычисляется относительно
+component root и применяется вместе с уже рассчитанным mount pose. Internal
+`INTO` child controller/equipment links пока не интерпретируются как MSH socket;
+если owner pose нельзя разрешить, его FX пропускается. Нативное сочетание child
+component scale, equipment attachment и CPT basis остаётся неустановленным.
+
## Vertex streams
Основные vertex streams имеют фиксированный source index. У type 3 `attr1`
diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md
index 10de209..df3f248 100644
--- a/docs/reference/render-frame.md
+++ b/docs/reference/render-frame.md
@@ -24,22 +24,91 @@
затенения, вызывается завершение рендерера, снимается `in_render`, затем
рассылается `end-of-render`.
-При retry кадра после пересоздания swapchain сохраняется прежний pending
-sample, включая уже вычисленные позы и состояния FX и материалов. Для нового
-drawable обновляются aspect, проекция и геометрия, зависящая от viewport; pose
-берётся из pending camera. Новые события управления камерой остаются в
-input controller и применяются следующим sample. Экранные billboards погоды
-повторно проецируются из закэшированных мировых головы и хвоста, без второго
-шага симуляции окружения и повторной отправки звуковых событий. Пауза нулевого
-drawable не добавляет время.
-
-Pending frame хранит пакет MAT0 для мира и, при наличии GPU-окружения, отдельный
-пакет неба. Пакеты содержат выбранные селектор и категорию конвейера, фазовые
-коэффициенты материала, четыре UV-преобразования и кортеж фаз дополнительных
-текстур. Повторное применение пакета при retry или нескольких resize не выбирает
-фазу заново и не продвигает RNG. Перед отрисовкой сначала применяются материалы
-мира и их порядок прозрачности, затем MAT0 неба, после чего viewport-зависимый
-проход неба записывает свои переопределения.
+В интерактивном preview часы материалов и окружения прибавляют полное время
+между обновлениями. Ограничение шага в 100 мс остаётся у движения свободной
+камеры и не обрезает эти часы. В режиме replay окружение получает разницу
+последних применённых значений общего control clock.
+
+Если swapchain просит повторить ещё не представленный кадр, preview сохраняет
+его общий clock, уже вычисленные позы, FX/RNG и фазы материалов. При изменении
+drawable обновляются камера, aspect и viewport, после чего sky, environment
+geometry и projected shadows пересобираются для нового размера.
+Положение и ориентация камеры берутся из сохранённого состояния
+ожидающего кадра; новый ввод мыши применяется при следующем сэмпле. Сэмпл
+погоды остаётся прежним: экранные billboards дождя и снега повторно
+проецируются из закэшированных мировых головы и хвоста, без второго шага
+симуляции окружения или повторной отправки звуковых событий. Следующий такт
+выбирается после успешного представления кадра; пауза нулевого drawable не
+добавляет время.
+
+## Прозрачность порталов MSH
+
+GOG `Terrain.dll` (SHA-256
+`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`),
+RVA `0x1B0F0`, заменяет diffuse alpha пакета без type 16 с batch bit `0x0008`.
+При `0x0010` аргумент кривой — `sqrt(distance)`, границы — `2.6 / fov` и
+`7.7999997 / fov`, наклон — `fov / (7.7999997 - 2.6)`. Без этого бита
+`0x0040` задаёт ноль; иначе границы — `75 * 1.3 / fov`, `95 * 1.3 / fov`,
+наклон — `fov / (95 * 1.3 - 75 * 1.3)`. До ближней границы alpha равна нулю,
+после дальней — единице. Внутри перехода alpha равна произведению смещения от
+ближней границы на наклон. Это замена alpha MAT0, а не дополнительный множитель.
+
+Метод расстояния `0x246A0` через `0x247B0` читает первую индексированную
+вершину пакета, преобразует её в мир и вычисляет квадрат расстояния до камеры.
+Текущий Ngi32 dispatch `FastProc+0x2C` указывает на RVA `0x1D6D0`:
+`RSQRTSS` возвращает приближение обратного корня, которое умножается на квадрат
+расстояния. Получается приближённое евклидово расстояние; дополнительный `FSQRT`
+ветви `0x0010` выполняется уже над ним. Хеш Ngi32 —
+`bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5`.
+Скаляр камеры в шестом float getter `0x4F5F0` и поле CShade `+0xBB4`
+проверены в работающем оригинале: оба содержат текущий горизонтальный FOV.
+
+`legacy_msh_portal_alpha` хранит кривую в `fparkan-render`. Приложение пока
+берёт первую индексированную вершину своего диапазона и обычный `sqrt`,
+поэтому выбор вершины после native CPU clipping и точность `RSQRTSS` не
+воспроизводятся. Счётчик `render_coverage portal_fade_packet_selection_approx`
+показывает число таких диапазонов. Поток type 16 сохраняет прежний отдельный
+путь и ещё не декодирован. Это ограничивает приёмку на частично отсечённых
+порталах и не позволяет считать интерьер принятой эталонной сценой.
+
+## Дистанционная кривая тумана
+
+Программный `CShade::ShadeIndexedStrided` в GOG Terrain.dll сравнивает квадрат
+дистанции с квадратами ближней и дальней границ. По RVA `0x2EEED–0x2EF45`
+видимость равна `1 - (distance² - near²) / (far² - near²)` между границами,
+единице перед ближней и нулю за дальней. Такой же закон использует отдельный
+путь цвета пакета `0x2F230`. Линейная интерполяция по самой дистанции давала
+слишком плотный туман в середине диапазона.
+
+В работающем оригинале границы 0 и `0x442389E6`, квадрат дистанции
+`0x47707B04` дали видимость `0x3F5B2B94`. Rust-тест проверяет передаваемые GPU
+квадраты границ по этому наблюдению. GLSL вычисляет квадрат расстояния вершины,
+после чего растеризатор интерполирует коэффициент по треугольнику.
+`native_fog.effective.sink_rgb` совпадает с `sky.packed[1]`, нижней границей
+освещения CShade. Обычный GPU fog color подтверждён отдельно: `CSky::Render`
+по RVA `0x48341` задаёт renderer default из цвета горизонта. В живом замере
+`0xFF404064` дошёл до `IDirect3DDevice7::SetRenderState(34)` в том же игровом
+тике. В другом ракурсе палитра `[0xFFA8A5CE, 0xFFABA9D5, 0xFFBAA9D5,
+0xFFABA9D5]` дала default `0xFFA9A7D1`. Это не покрывает временные изменения
+цвета отдельных проходов: например, native render path устанавливает чёрный
+цвет и затем восстанавливает сохранённый.
+Выбор проходов по native shade-state пока приближён общим туманом мира и
+считается как `render_coverage fog_pass_eligibility_approx`.
+
+## Радиус Control Op1
+
+GOG `Effect.dll` (SHA-256
+`386a3e8db1ce23f61957825d633c7dae1ed40c25cab0cf10a534385fd7cee789`),
+RVA `0xF6E0`, преобразует направление текущей матрицей, сохраняет его длину
+перед нормализацией и умножает на неё интерполированный локальный радиус.
+Умножение видно по RVA `0xFB13`; перед LightManager `+0x10` неположительный
+результат заменяется на `0.01`. В живом «Конструкторе» для синего источника
+завода локальный радиус 7 и длина `0x409B1F91` дали радиус `0x4207BB9F`.
+Это же значение проверяется Rust-тестом общего Op1 evaluator.
+
+Случайные добавки цвета и радиуса ещё не воспроизводятся; приложение сообщает
+число таких Control-команд как `control_op1_jitter_unsupported` и использует
+их авторские значения. У синего источника в проверенном захвате добавки нулевые.
## Элемент отрисовки
@@ -69,21 +138,17 @@ Pending frame хранит пакет MAT0 для мира и, при налич
глубину. За фиксированными диапазонами окружения сохраняются их явные состояния.
Для 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 и не меняет общий
+raw-флаге `Batch20` `0x0008` получает переопределение alpha по
+[дистанционной кривой портала](#прозрачность-порталов-msh). Bit `0x0010`
+имеет приоритет; иначе bit `0x0040` задаёт нулевую alpha, а оставшаяся
+ветвь использует границы 75/95 с поправкой на FOV. При наличии type 16 или
+отсутствии `0x0008` переопределения нет. Оно относится к одному draw range и не меняет общий
материал; alpha `0` временно задаёт число индексов `0`, а при очистке
переопределения исходное число индексов восстанавливается.
-Общий fade по расстоянию пока не реализован. Открыты точная метрика расстояния,
-выбор первого CPU-принятого треугольника диапазона и эквивалентность frustum- и
-culling-отбору оригинала. Поэтому состояния `0x0108` и `0x0118` оставляют alpha
-MAT0, пока эти входные данные не установлены.
+Fade обновляется с камерой каждый кадр. Открыт выбор первой принятой вершины
+после native CPU clipping: приложение пока берёт первую индексированную
+вершину диапазона и считает это приближение в `render_coverage`.
В превью с фиксированной камерой диапазоны туманности и звёзд, купола и облаков
явно помечаются как прозрачный фон. Непрозрачные диапазоны мира остаются на
@@ -117,6 +182,10 @@ RVA `0x13D90`. Эти данные подтверждают только порÑ
путь превью принимает входные данные с матрицей `selector0_words`, полученные через
[процедуру захвата нативного кадра](../../tools/native-frame-capture/README.md).
+Формат захвата сохраняет локальную матрицу `selector0_words` и, когда она
+доступна, записывает `effective_world_matrix_words` с маркером пространства
+`world`.
+
Процедура захвата записывает наблюдавшийся путь миссии и значение
`atmosphere_seconds`, если проверка потока, поколения и часов прошла успешно.
В превью с фиксированной камерой эта абсолютная фаза расписания используется,
@@ -156,6 +225,17 @@ RVA `0x4EBE0` использует соглашение о вызовах `__std
`right - left` на `bottom - top`. Размер окна превью закреплён: если при
запуске или после ненулевого изменения окна физический размер не совпадает с
заданным, приложение завершает работу, а не растягивает изображение.
+
+Проекция во время рисования может отличаться от восстановленного состояния
+камеры после обхода мира. В GOG у `CStridedPrimitive::RenderVB` (Terrain RVA
+`0x1D110`) renderer содержит `near = 0.5`, а на границе World3D RVA `0x13CE4`
+уже `near = 5`. Поэтому захват отдельно сохраняет `geometry_projection` при
+первом вызове геометрии с той же мировой матрицей камеры. Поток, поколение,
+renderer, viewport, far и FOV сверяются с последующей границей; только
+подтверждённая запись используется при воспроизведении. Иначе пол в трёх
+единицах от камеры может исчезнуть из-за чужой ближней плоскости. Старые JSON
+без этой записи сохраняют прежнее поведение с явным счётчиком
+`captured_projection_unverified`; их нужно переснять для проверки близких поверхностей.
Сравнивайте результат с фрагментом исходного PNG по прямоугольнику
`[left, top, right, bottom]`. В этом режиме `FreeFlightCamera` не создаётся,
но при повторной отрисовке обновляются окружение и проецируемые тени для
@@ -163,14 +243,26 @@ RVA `0x4EBE0` использует соглашение о вызовах `__std
события, слушателю задаются захваченные позиция, направление вперёд и верхняя
ось; начальная сортировка диапазонов мира использует ту же позицию.
+Если в JSON присутствует пара `effective_world_matrix_space: "world"` и
+`effective_world_matrix_words`, превью использует мировую матрицу. Матрица
+`selector0_words` остаётся локальной и используется как запасной вариант только
+при отсутствии мировых полей, если `parented_camera` не равно `true`. Для
+родительской камеры без эффективной мировой матрицы вход отклоняется.
+
Захват может содержать наблюдавшийся путь миссии и проверенное значение
`atmosphere_seconds`; последнее используется только при отсутствии явного
параметра командной строки. Это значение фиксирует фазу расписания атмосферы
-при сравнении с неподвижной камерой, тогда как FX продолжает получать реальное
+при сравнении с неподвижной камерой, тогда как FX по умолчанию получает реальное
время кадра. `simulation_time_seconds`, погода, RNG и полное состояние миссии
остаются неизвестными, поэтому совпадение камеры и фазы не подтверждает
визуального совпадения с нативным кадром.
+Параметр `--control-clock-ms` воспроизводит единые часы MSH/FX от нуля до
+выбранного значения в миллисекундах за указанное число кадров. Без этого
+параметра часы идут в реальном времени. Для `--readback-sequence-out` этот
+управляемый режим обязателен. Он не восстанавливает
+`simulation_time_seconds`, погоду, RNG или полное состояние миссии.
+
Для неба превью вычисляет направление как `atan2(forward.x, forward.y)`. Оно
совпадает со вторым выходным значением, вычисляемым в `Terrain.dll` по RVA
`0x4F9C0`; при выборе сектора экранного градиента `CSky` добавляет π по RVA
@@ -183,15 +275,9 @@ RVA `0x4EBE0` использует соглашение о вызовах `__std
`1.3` рад — параметры, наблюдавшиеся в проверенном контексте GOG Ngi32
([формула native projection](../tomes/05-render.md#камера-и-размещение-в-оригинале)).
Для отношения ширины к высоте `a` превью вычисляет вертикальный угол
-```rust
-fn vertical_fov(self, aspect: f32) -> f32 {
- 2.0 * ((self.horizontal_fov * 0.5).tan() / aspect).atan()
-}
-```
-
-Код заменяет невалидный `aspect` на отношение 16:9; горизонтальный угол
-остаётся `1.3` рад после изменения размера окна, а новый viewport и матрица
-камеры применяются при следующей отрисовке.
+`2 * atan(tan(horizontal_fov / 2) / a)`. Поэтому горизонтальный угол остаётся
+`1.3` рад после изменения размера окна; новый viewport и матрица камеры
+применяются при следующей отрисовке.
Проекция направления не сравнивает косинус угла с ближней дистанцией: она
отбрасывает только лучи за камерой и почти параллельные плоскости изображения
@@ -254,6 +340,11 @@ D3D7 в `fparkan-render`, а затем один раз меняет знак в
- переход на запасной набор возможностей устройства;
- квантование времени симуляции.
-Для отладки требуется детерминированный захват кадра: состояние камеры,
-идентификаторы видимых объектов, список элементов отрисовки, ключи конвейера,
-матрицы и хэши промежуточных буферов.
+Параметр `--readback-sequence-out` сохраняет необработанные байты каждого
+отправленного кадра; в имя файла записываются индекс кадра, значение общих
+управляемых часов и размер изображения. Байты соответствуют выбранному формату
+изображения swapchain Vulkan и не гарантируют порядок каналов R, G, B, A. Числовое
+формат выводится как `readback_format`; расширение `.rgba` само по себе порядок
+каналов не определяет. Последовательность не содержит структурированных ID
+видимых объектов, списка элементов отрисовки, ключей конвейера, матриц или
+хэшей промежуточных буферов.
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index ef7dc52..cfd352a 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,59 @@ emit не должен повторно менять игровое состоя
типизировать header и resource references, а body редких commands сохранять raw
до подтверждения field-level semantics.
+В `fparkan-game` выбранные Control FX проходят отдельный transparent path.
+Начальная CTL group запускает только строки без predicates, chain или state
+gate. Type 4 point list задаёт локальную матрицу; текущие MSH owner и mounted
+node poses входят в transform до локальной FX basis. Реализованные visuals
+охватывают Op3 modes 0–2, Op4, Op7/8 particle pools и Op9 generated mesh. Op1
+selector 1 добавляет point lights без native flag `0x80000000` в общий diffuse
+список; остальные active records сохраняют flags для специальных путей и
+projected shadows. Радиус источника масштабируется длиной его преобразованного
+направления до нормализации. Например, локальный радиус 7 при масштабе узла
+около 4,85 освещает область радиусом около 34. Потеря этого множителя оставляла
+синий свет завода почти незаметным. Неположительный итог заменяется на 0,01.
+Случайные добавки к цвету и радиусу пока явно считаются в
+`render_coverage control_op1_jitter_unsupported`.
+
+Эффект может одновременно рисовать поверхность и издавать звук. Для Op2
+приложение создаёт отдельный звуковой источник у каждого экземпляра команды:
+несколько одинаковых механизмов слышны одновременно и не заменяют дождь.
+Позиция следует анимируемому узлу, а фаза включает, обновляет и останавливает
+loop. Обычная строка Control создаёт экземпляр с нулевой постоянной фазой;
+звуки с таким режимом ожидают отдельной управляющей команды. Периодические
+звуки работают по часам уже в preview. Устройство вывода остаётся в приложении,
+а правила переходов и параметров — в `fparkan-fx::ambient_sound`
+([подробнее](../reference/environment-effects.md#звуки-control)).
+
+Туман также зависит от расстояния нелинейно: между ближней и дальней границами
+движок интерполирует по квадрату дистанции. При границах 0 и 700 точка в 350
+получает четверть тумана, а не половину. Это сохраняет контраст среднего плана.
+Effect renderer подавляет header bit `0x800` только при
+render selector 0. Native AniMesh component path захвачен с selector 1, а в
+его 53-entry manager присутствуют FX entries с header `0xD00`; preview
+использует selector 1 и пропускает такие visual commands. Изменения manager
+`A8`, которые задают runtime phase/header gate, пока не моделируются. Header
+bit `0x10` относится к activation lifecycle и не выключает draw.
+
+Для visual effect renderer использует MAT0, указанный самой командой. Его
+phase выбирается по normalized FX phase, а не по wall-clock clock материала
+мира. FX opacity остаётся в vertex alpha, MAT0 alpha для этой FX binding
+фиксируется на единице, texture alpha остаётся в shader. Preview передаёт FX
+через SourceAlpha/read-only-depth state. MAT0 phases проходят локальный
+eligibility predicate additive-RGB approximation; native `flag40` и
+directional-light branch остаются неразрешёнными.
+
+Op9 генерирует индексированную полусферу с отдельными vertex и UV данными и
+отправляет каждый quad и cap как отдельный draw range. Native packet distance
+callback берёт первую индексированную вершину (`Terrain.dll:0x246A0` через
+`0x247B0`), преобразует её согласно режиму packet и использует евклидово
+расстояние до камеры. Queue размещает большие расстояния раньше меньших;
+равный ключ вставляется в найденный binary-search midpoint, поэтому порядок
+равных packets не стабилен по времени поступления. Preview повторяет этот ключ
+и insertion order. Renderer category 0 остаётся highest-detail mesh; native
+category вычисляется из projected sphere size и quality table, поэтому
+фиксированная категория не является native LOD parity.
+
## Полный кадр
Крупный вход в world render проходит через `World3D::stdRenderGame`. Доказан
@@ -947,6 +1009,12 @@ sun-ray queries поэтому используют исходные коордÐ
она не равна одному лишь обратному преобразованию камеры.
В проверенном GOG контексте Ngi32 использовались `n=0.5`, `z=700`, `f=1.3`.
+Ближняя плоскость относится к конкретному проходу. После рисования движок
+может вернуть прежние настройки камеры: захват только в конце мира видел
+`n=5` и при воспроизведении вырезал пол перед игроком. Формат захвата поэтому
+хранит проверенную проекцию вызова геометрии отдельно от состояния камеры;
+подробности приведены в [описании кадра](../reference/render-frame.md#предпросмотр-по-захвату-камеры-d3d7).
+
У GOG `Terrain.dll + 0x7355c` хранит указатель на активную внешнюю камеру.
Её selector 0 возвращает 16 слов начиная с `+0x20`, перенос находится в словах
3, 7, 11. Интерфейс `LoadCamera` расположен отдельно, по `+0x134` объекта.
@@ -962,11 +1030,39 @@ Node38, а диапазоны граней — в 68-байтовых Slot68. В
проверяются на единственное назначение, а выходящий за массив, повторный или
неподдерживаемый диапазон считается ошибкой загрузки.
-Первый рендер карты, terrain-запросы высоты и пересечения, receiver-ы теней и
-обрезка контуром зданий используют только LOD0. Отбор сохраняет порядок
-исходных граней, а контурный проход отдельно переводит индекс спроецированной
-грани обратно в исходный. LOD1 пока не подставляется по расстоянию: такой
-переход не реализован.
+Рисуемый уровень выбирается по ячейкам, а не по расстоянию до отдельного
+треугольника. Начало сетки находится в первых двух float заголовка Slot68,
+размер ячейки — в границах первого слота LOD0. Атрибут 2 потока Node38 задаёт
+число ячеек по X; индекс ячейки равен `x * count_y + y`. Приложение проверяет,
+что границы всех слотов образуют эту регулярную сетку. Все 33 карты GOG
+проходят такую проверку, включая вырожденные грани на общей границе ячеек:
+принадлежность грани берётся из слота, а не угадывается по её центру.
+
+Координаты камеры переводятся в целые координаты сетки округлением вниз.
+Если `dx * dx + dy * dy >= 9` и горизонтальный FOV не меньше 0,5 радиана,
+выбирается LOD1. При увеличении масштаба, отсутствии дальнего слота или
+наличии геометрического patch остаётся LOD0. Камера за границей карты не
+прижимается к крайней ячейке для этого расчёта. Правило восстановлено по
+GOG `Terrain.dll` ([сборка и SHA-256](../reference/original-binaries.md)):
+`CLandscape::Render`, RVA `0x11340`, выбор уровня `0x11878–0x11A03`.
+Исполнение оригинала подтверждает выбор дальнего слота, а getter камеры
+`Ngi32.dll` RVA `0x4D960` возвращает именно FOV.
+
+Основания строений сохраняют подробную геометрию. Нативная регистрация
+patch (`Terrain.dll` RVA `0x12E20`) проверяет его треугольники против каждой
+ячейки через `mrnParaIntersectsPoly`. Поэтому учитываются и соседние ячейки,
+которых касается край треугольника. Перед проверкой координаты нормализуются
+в `f32` относительно центра ячейки; замена этой операции сравнением мировых
+границ меняет результат на общих рёбрах. На «Конструкторе» игнорируемый
+ресурсный тест сверяет все 22 ячейки с принудительным LOD0 с набором,
+полученным в живом оригинале.
+
+Vulkan получает оба уровня один раз. Диапазоны индексов разделены по
+материалу и исходной ячейке; на каждом кадре включается только выбранный
+уровень. Сгенерированные отмостки остаются включены. Terrain-запросы высоты
+и пересечения, receiver-ы теней и обрезка контуром зданий используют
+канонический LOD0. Отбор сохраняет порядок исходных граней, а контурный проход
+отдельно переводит индекс спроецированной грани обратно в исходный.
```rust
let assignments = mesh.face_lod_assignments()?;
@@ -984,9 +1080,10 @@ for (face, assignment) in mesh.faces.iter().zip(assignments.iter()) {
исходные фрагменты рельефа вместе с треугольниками отмостки в одном наборе.
`TerrainWorld::with_basement_foundation` принимает этот набор во владение и
устанавливает те же координаты и маски в BVH высотных запросов и пересечений.
-Предпросмотр миссии на Vulkan пока строит только LOD0: связывание BAS-записей
-с этой подготовкой ещё не подключено, поэтому наличие API не означает, что
-геометрия миссии уже обрезана.
+Предпросмотр миссии связывает BAS-записи с этой подготовкой до загрузки
+материалов. Полученная поверхность общая для изображения, теней и запросов;
+поправка размещения основания один раз применяется к корню строения и его
+присоединённым компонентам.
Внешнее кольцо должно лежать на области LOD0, а каждый его сегмент — оставаться
покрытым текущей поверхностью. Непокрытый сегмент или пересекающиеся контуры
@@ -997,13 +1094,33 @@ for (face, assignment) in mesh.faces.iter().zip(assignments.iter()) {
## Материалы ландшафта
-Рядом с `Land.msh` лежат `Land1.wea` и `Land2.wea`. Старший байт
-`TerrainFace28.material_tag` выбирает материал первой таблицы, младший —
-второй. `0xff` в старшем байте означает отсутствие первого слоя.
+Рядом с `Land.msh` лежат `Land1.wea` и `Land2.wea`. Младший байт
+`TerrainFace28.material_tag` выбирает базовый материал `Land1.wea`, старший —
+дополнительный материал той же таблицы. `0xff` в старшем байте означает
+отсутствие дополнительного слоя. `Land2.wea` содержит соответствующие этим
+двум строкам detail-материалы.
В AutoDemo встречаются теги `0102`, `0203`, `ff01`, `ff02`, `ff03`.
Менеджер World3D получает селектор `(table_index << 16) | material_index`
и возвращает выбранную фазу MAT0 размером 76 байт.
+Таблица, строка и блок анимации — разные части запроса. В быстром проходе
+с двумя текстурами `CShade` запрашивает для Land2 блок анимации **1**. Он может
+выбирать другую текстуру, а не только другие коэффициенты освещения:
+например, в «Конструкторе» это `L09M.0` вместо `L09.0`. Подмена блока первым
+приводит к умножению базового изображения на неподходящую detail-текстуру и
+меняет яркость и контраст земли. `TerrainMaterialSelection` сохраняет номер
+блока отдельно от packed material key; приложение использует его при загрузке
+дескриптора и в последующих кадрах. Если второго блока нет, общий MAT0 sampler
+возвращается к первому, как оригинальный `GetMaterialPhase`.
+
+В GOG `Terrain.dll` (сборка из [таблицы бинарников](../reference/original-binaries.md))
+быстрая ветвь находится в RVA `0x1A7AC–0x1A7FB`. Живое исполнение передаёт
+handle `0x00010002`, block `1` и устанавливает режим текстур `9`; первая
+текстура умножается на diffuse, вторая — на результат с множителем два.
+Ближний дополнительный проход с порогом `260 / FOV` относится к fallback без
+этого режима. На проверенном устройстве он не выбирается, поэтому переносить
+его дистанционный порог в быстрый проход нельзя.
+
Type 18 в `Land.msh` оригинал называет microtexture mapping chunk;
он содержит четырёхбайтовые записи. Type 11 хранит пары `u16`, выбранные
таблицей terrain slots. Их первое поле — ключ shade cache, а не номер строки
@@ -1027,6 +1144,16 @@ lightmap state передаются в `VulkanStaticMaterial`, а shader посÐ
получается ключ Vulkan pipeline. Alpha reference передаётся отдельно через
push constant; он не требует нового pipeline.
+Набор pipelines создаётся до первого draw. Для каждого исходного состояния
+готовятся его авторский вариант, прозрачный вариант с сохранённым alpha-test
+и пять категорий MAT0 с обеими политиками глубины. Cull и decal bias остаются
+исходными; одинаковые ключи объединяются. Смена категории материала или
+прозрачности диапазона выбирает готовый ключ, сохраняя descriptors, buffers и
+framebuffers. Vulkan `VkPipelineCache` принадлежит renderer, используется всеми
+созданиями pipelines и переживает пересоздание swapchain. На диск он пока не
+сохраняется. Счётчик `render_pipeline_cache` показывает число созданных
+pipelines, включая новые наборы после resize.
+
Рабочий путь `fparkan-game` загружает terrain и все выбранные mission roots
(по умолчанию весь список объектов), разрешая локальные material slots через
WEAR и MAT0 до объединения геометрии. Индексы GPU имеют тип `u32`: вся карта
@@ -1034,55 +1161,80 @@ WEAR и MAT0 до объединения геометрии. Индексы GPU
`u16`. Terrain передаёт в shader базовый, detail и overlay stages, lightmap и
animation phase; object batches сохраняют их legacy blend/depth/alpha state.
-После загрузки world path создаёт интерактивную free-flight камеру (`WASD`,
-`E/Q`, `Shift`, `Ctrl`, RMB relative-look), обновляет listener и собирает
-environment frame для каждого подготовленного кадра. Event loop один раз
-сэмплирует камеру, общий animation clock, геометрию и окружение перед запросом
-redraw; redraw потребляет это состояние. Если surface требует пересоздать
-swapchain, тот же sample времени и состояние мира отправляются повторно.
-Перед повторной отправкой projection камеры и viewport-зависимые environment
-и shadow geometry обновляются для нового размера без продвижения сцены.
-Счётчик и replay clock продвигаются только после результата `Presented`.
-После шага движения камера проверяет высоту
-`TerrainWorld`: над конечной поверхностью сохраняется начальный просвет, а вне
-поверхности высота не ограничивается.
-
-Для каждого отрисовываемого строения (корневого объекта класса `FORT`) и для
-каждого его отрисовываемого компонента создаётся отдельная запись проверки. Она
-использует те же текущие матрицы узлов и присоединённых компонентов, что и
-геометрия.
-После ограничения целевой позиции по TerrainWorld камера проверяет непрерывный
-отрезок от прежней позиции до цели; при пересечении она остаётся в прежней
-позиции, сохраняя новый поворот. Повторная отправка ожидающего кадра не
-применяет движение второй раз. Это ограничение диагностической свободной
-камеры, а не правило игрового или следящего режима оригинала.
+При первоначальной загрузке одинаковые текстуры разных material slots
+используют один GPU image. Ключ включает размеры, RGBA8 и всю авторскую
+mip-цепочку: совпадения только базового изображения недостаточно. Хеш ускоряет
+поиск, а полное сравнение данных исключает ошибочное объединение при коллизии.
+Слоты сохраняют собственные descriptors, UV и параметры материала. При первой
+смене общей текстуры renderer создаёт отдельный image только для изменяемого
+слота; следующие фазы обновляют его на месте. Поэтому анимация одного материала
+не меняет соседний. Строка `render_texture_upload` показывает число слотов,
+начальных GPU images и отделений при обновлении. CPU-копии исходных пикселей
+в материалах эта оптимизация не объединяет.
+
+Рельеф образует неизменяемое начало общего vertex buffer. Control-анимация,
+FX и окружение обновляют следующие вершины, а новые индексы нужны только
+геометрии проекционных теней в конце index buffer. Поэтому кадр записывает
+лишь эти изменяемые части. При росте буфера новая память получает все данные,
+включая неизменяемое начало; уменьшение числа теней не уменьшает выделенную
+ёмкость. Это сохраняет номера вершин, диапазоны отрисовки и порядок проходов.
+
+Перед записью общих ресурсов renderer ждёт fences всех отправленных кадров.
+Одного fence текущего кадрового слота недостаточно: другой кадр ещё может
+читать ту же память. Ожидание всего устройства остаётся на пересоздании
+swapchain, диагностическом readback и завершении работы. Загрузка изменённых
+текстур пока использует отдельную синхронную transfer-команду. Строка
+`render_geometry_upload` показывает записанные vertex/index bytes и объём,
+который потребовался бы при полной перезаливке, без начальной загрузки.
+После загрузки world path создаёт интерактивную free-flight камеру (`WASD`,
+`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 может
+начальная сортировка world draw использует её же position. Облака в обычном
+запуске движутся с отдельным 50-секундным циклом UV; фон и звёзды имеют свои
+периоды. Изменение цвета неба по расписанию не заменяет это движение.
+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. Дополнительный
+`sky_uv_clock` фиксирует положение движущихся текстур на том же кадре;
+без него их начальная фаза не подтверждена. Фазы 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 208f911..6edfb7a 100644
--- a/docs/tomes/06-behavior.md
+++ b/docs/tomes/06-behavior.md
@@ -169,8 +169,10 @@ handler лишь инициирует execution context (`+0x50 = 1`); его ga
показывает предупреждение для чисел выше `10000`, но не отменяет создание AI.
Rust исполняет подтверждённый поднабор первого `Init`: действие `Handler(19)`
-и числовые literal assignments, описанные ниже. Поддержанные записи применяются
-к состоянию лишь после успешного прохода всего первого `Init`.
+и формулы из одного атома либо двух атомов с бинарным `+` или `-`. Здесь атом —
+число или имя переменной. Поддержанные шаги сразу обновляют временную копию
+varset, поэтому следующая формула читает уже записанные значения. К состоянию
+клана записи попадают лишь после успешного прохода всего первого `Init`.
Если встретилась неподдержанная форма, preview сохраняет пакет, сообщает
`Unsupported` и оставляет значения по умолчанию; при полной загрузке та же форма
становится ошибкой. Это политика Rust для ограниченного исполнителя, а не правило
@@ -186,15 +188,54 @@ Rust исполняет подтверждённый поднабор перво
нули в `dT8` и `dT9`.
Клан 1 той же миссии использует первые девять строк `scream.fml`: восемь
проверенных значений попадают в `DWORD` с усечением дробной части, а `0.8`
-сохраняется как число `float`. Пока Rust вычисляет только числовой литерал,
-начинающийся с цифры; операторы, ссылки на переменные и другие формы формул
-остаются явными ошибками. В тесте полной загрузки миссия `Mission.01`
-проходит этот `Init`; `Single.01` останавливается на следующем неподдержанном
-`Handler(43)` в clan 1, записи 10. Никакое значение не подставляется вместо
-этого действия.
+сохраняется как число `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 мс. Эта
@@ -213,7 +254,7 @@ SuperAI для записей кланов, затем обновляет Behavi
всё равно устанавливается. В полной загрузке Rust вызывающая сторона передаёт
значение времени очереди; после регистрации объектов загрузчик последовательно
подготавливает каждый сценарный пакет и выполняет восстановленные действия
-`Handler(19)` и числовые literal assignments из первого `Init`. Статический
+`Handler(19)` и поддержанные формульные присваивания из первого `Init`. Статический
просмотр не запускает Behavior, но может сохранить эти записи `Init` в сценарном
varset.
@@ -260,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, как он затем
+обновляется и как работает полный игровой цикл.
+
Список распределителя хранит не только участников, но и долю каждого. При
добавлении двух объектов оба получают по половине общего веса. Если добавить
третий, движок оставляет долю новой записи около одной трети, а оставшийся вес
@@ -527,8 +587,14 @@ let cost = calc_full_research_cost(&candidate, &table)?;
штатный Handler(43) и запуск миссии здесь не проверялись.
Владелец начальной записи исследования — индекс текущего прохода по кланам.
-Движок сохраняет его в `descriptor[0]`, а затем переносит в контекст, чтобы
-связать запрос с AI-данными того же клана.
+В 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`, задающего число вызовов
@@ -536,6 +602,29 @@ 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 проверок.
@@ -972,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
@@ -1030,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
@@ -1310,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`; этот коэффициент не является ставкой
@@ -1443,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`. Читатель потребляет
@@ -1454,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/NativeFrameCapture.cs b/tools/native-frame-capture/NativeFrameCapture.cs
index 6371ae6..9721049 100644
--- a/tools/native-frame-capture/NativeFrameCapture.cs
+++ b/tools/native-frame-capture/NativeFrameCapture.cs
@@ -49,6 +49,8 @@ internal static partial class NativeFrameCapture
private const string Iron3dName = "iron3d.dll";
private const uint RenderGameRva = 0x13BD0;
private const uint ProjectionSnapshotRva = 0x13CE4;
+ private const uint GeometryProjectionRva = 0x1D110;
+ private const uint SkyRenderRva = 0x48110;
private const uint RenderReturnRva = 0x13D7B;
private const uint AtmospherePhaseRva = 0x421DC;
private const uint FogConsumerRva = 0x2F230;
@@ -101,6 +103,10 @@ internal static partial class NativeFrameCapture
public uint ProjectionGeneration;
public uint ProjectionThreadId;
public ProjectionReadback Projection;
+ public ProjectionReadback GeometryProjection;
+ public uint GeometryProjectionGeneration;
+ public uint GeometryProjectionThreadId;
+ public SkyUvReadback SkyUv;
public uint CameraWorldGameTimeWord;
public bool CameraWorldGameTimeWordReadable;
public uint WorldGameTimeWord;
@@ -129,6 +135,11 @@ internal static partial class NativeFrameCapture
public string NativeFogStatus;
}
+ private sealed class SkyUvReadback
+ {
+ public uint Object, Origin, RawClock, ElapsedMilliseconds, Generation, ThreadId;
+ }
+
private sealed class AtmosphereReadback
{
public uint SampleThreadId;
@@ -201,6 +212,8 @@ internal static partial class NativeFrameCapture
private sealed class SelectedCameraState
{
public SelectedCameraInput Input;
+ public int PrecedingFramesRemaining;
+ public int PrecedingFramesCompleted;
public uint CandidateCameraPointer;
public uint CandidateThreadId;
public uint CandidateGeneration;
@@ -226,6 +239,47 @@ internal static partial class NativeFrameCapture
public uint OriginalParentPointer;
}
+ private static SelectedCameraState NextSelectedWarmupFrame(SelectedCameraState previous)
+ {
+ if (previous.PrecedingFramesRemaining <= 0 || !previous.RestoreVerified
+ || !previous.Restored || !previous.MatrixIntactAtReturn
+ || !previous.ProjectionMatchesInput || previous.PixelAttributionInvalidated)
+ throw new InvalidOperationException("Cannot advance a selected camera before a verified frame and exact restoration.");
+ return new SelectedCameraState
+ {
+ Input = previous.Input,
+ PrecedingFramesRemaining = previous.PrecedingFramesRemaining - 1,
+ PrecedingFramesCompleted = previous.PrecedingFramesCompleted + 1,
+ CandidateCameraPointer = previous.CameraPointer,
+ CandidateThreadId = previous.ThreadId,
+ CandidateGeneration = previous.Generation,
+ CandidateProjectionVerified = true
+ };
+ }
+
+ private static void SelfCheckSelectedCameraWarmup()
+ {
+ SelectedCameraState first = new SelectedCameraState
+ {
+ PrecedingFramesRemaining = 2, CameraPointer = 123, ThreadId = 11, Generation = 8,
+ Restored = true, RestoreVerified = true, MatrixIntactAtReturn = true,
+ ProjectionMatchesInput = true
+ };
+ SelectedCameraState second = NextSelectedWarmupFrame(first);
+ if (second.PrecedingFramesRemaining != 1 || second.PrecedingFramesCompleted != 1
+ || second.CandidateCameraPointer != 123 || second.CandidateThreadId != 11
+ || second.CandidateGeneration != 8 || second.Applied || second.RestoreVerified)
+ throw new InvalidOperationException("Selected camera warmup transition self-check failed.");
+ bool unrestoredRejected = false;
+ try { NextSelectedWarmupFrame(second); }
+ catch (InvalidOperationException) { unrestoredRejected = true; }
+ if (!unrestoredRejected) throw new InvalidOperationException("Unrestored warmup frame was accepted.");
+ second.Restored = second.RestoreVerified = second.MatrixIntactAtReturn = second.ProjectionMatchesInput = true;
+ SelectedCameraState final = NextSelectedWarmupFrame(second);
+ if (final.PrecedingFramesRemaining != 0 || final.PrecedingFramesCompleted != 2)
+ throw new InvalidOperationException("Selected camera final frame self-check failed.");
+ }
+
private sealed class BoundaryTraceProgress
{
public bool BeginSceneSeen;
@@ -695,6 +749,104 @@ internal static partial class NativeFrameCapture
return match;
}
+ private static bool GeometryProjectionMatchesFrame(FrameSnapshot frame)
+ {
+ ProjectionReadback geometry = frame.GeometryProjection;
+ ProjectionReadback boundary = frame.Projection;
+ if (geometry == null || boundary == null || !geometry.Verified || !boundary.Verified || geometry.Mode == 0
+ || frame.GeometryProjectionGeneration == 0
+ || frame.GeometryProjectionGeneration != frame.CameraGeneration
+ || frame.GeometryProjectionGeneration != frame.ProjectionGeneration
+ || frame.GeometryProjectionThreadId != frame.CameraThreadId
+ || frame.GeometryProjectionThreadId != frame.ProjectionThreadId
+ || geometry.Renderer != boundary.Renderer || geometry.Far != boundary.Far
+ || geometry.Fov != boundary.Fov) return false;
+ for (int i = 0; i < 4; i++)
+ if (geometry.Viewport[i] != boundary.Viewport[i]) return false;
+ return true;
+ }
+
+ private static void AppendGeometryProjectionJson(StringBuilder json, FrameSnapshot frame)
+ {
+ if (!GeometryProjectionMatchesFrame(frame))
+ {
+ json.AppendLine(" \"geometry_projection\": null,");
+ return;
+ }
+ ProjectionReadback p = frame.GeometryProjection;
+ json.AppendLine(" \"geometry_projection\": {");
+ json.AppendLine(" \"verified\": true,");
+ json.AppendLine(" \"sample_boundary\": \"Terrain+0x1D110, before indexed geometry draw\",");
+ json.AppendLine(" \"terrain_module_sha256\": " + JsonString(ExpectedSha256[TerrainName]) + ",");
+ json.AppendLine(" \"frame_generation\": " + frame.GeometryProjectionGeneration + ",");
+ json.AppendLine(" \"thread_id\": " + frame.GeometryProjectionThreadId + ",");
+ json.AppendLine(" \"viewport\": [" + String.Join(", ", p.Viewport) + "],");
+ json.AppendLine(" \"near_plane\": " + p.Near.ToString("R", CultureInfo.InvariantCulture) + ",");
+ json.AppendLine(" \"far_plane\": " + p.Far.ToString("R", CultureInfo.InvariantCulture) + ",");
+ json.AppendLine(" \"field_of_view_radians\": " + p.Fov.ToString("R", CultureInfo.InvariantCulture));
+ json.AppendLine(" },");
+ }
+
+ private static SkyUvReadback CaptureSkyUv(IntPtr process, uint terrainBase, uint worldBase,
+ byte[] context, FrameSnapshot frame, uint threadId)
+ {
+ uint obj = BitConverter.ToUInt32(context, ContextEcxOffset);
+ uint stack = BitConverter.ToUInt32(context, ContextEspOffset);
+ if (frame.Camera == null || frame.CameraThreadId != threadId || frame.CameraGeneration == 0
+ || ReadU32Exact(process, stack + 4) != frame.Camera.Camera
+ || ReadU32Exact(process, obj) != unchecked(terrainBase + 0x65F98)) return null;
+ uint origin = ReadU32Exact(process, obj + 8);
+ uint rawClock = ReadU32Exact(process, unchecked(worldBase + 0x32A38));
+ uint elapsed = unchecked(rawClock - origin);
+ float[] expected = new float[] {
+ (elapsed % 300000) * 3.3333333e-6f, (elapsed % 300000) * 3.3333333e-6f,
+ (elapsed % 100000) * 1e-5f, (elapsed % 100000) * 1e-5f,
+ (elapsed % 50000) * 2e-5f, 0 };
+ uint[] pointers = new uint[] { ReadU32Exact(process, obj + 0x48),
+ ReadU32Exact(process, obj + 0x40), ReadU32Exact(process, obj + 0x44) };
+ for (int i = 0; i < expected.Length; i++)
+ {
+ float actual = BitConverter.ToSingle(BitConverter.GetBytes(
+ ReadU32Exact(process, pointers[i / 2] + (uint)(i % 2) * 4)), 0);
+ if (Single.IsNaN(actual) || Single.IsInfinity(actual)
+ || Math.Abs(actual - expected[i]) > 1e-6f)
+ {
+ Log("SKY_UV_CLOCK_REJECTED zenith coordinate does not match the current world clock.");
+ return null;
+ }
+ }
+ Log("SKY_UV_CLOCK generation=" + frame.CameraGeneration + " tid=" + threadId
+ + " raw=" + rawClock + " origin=" + origin + " elapsed_ms=" + elapsed);
+ return new SkyUvReadback { Object = obj, Origin = origin, RawClock = rawClock,
+ ElapsedMilliseconds = elapsed, Generation = frame.CameraGeneration, ThreadId = threadId };
+ }
+
+ private static bool SkyUvMatchesFrame(FrameSnapshot frame)
+ {
+ SkyUvReadback sky = frame.SkyUv;
+ return sky != null && sky.Generation == frame.CameraGeneration
+ && sky.Generation == frame.ProjectionGeneration && sky.ThreadId == frame.CameraThreadId
+ && sky.ThreadId == frame.ProjectionThreadId && frame.WorldGameTimeWordReadable
+ && sky.RawClock == frame.WorldGameTimeWord && frame.CameraWorldGameTimeWordReadable
+ && sky.RawClock == frame.CameraWorldGameTimeWord;
+ }
+
+ private static void AppendSkyUvJson(StringBuilder json, FrameSnapshot frame)
+ {
+ if (!SkyUvMatchesFrame(frame)) { json.AppendLine(" \"sky_uv_clock\": null,"); return; }
+ SkyUvReadback sky = frame.SkyUv;
+ json.AppendLine(" \"sky_uv_clock\": {");
+ json.AppendLine(" \"verified\": true,");
+ json.AppendLine(" \"sample_boundary\": \"Terrain+0x48110, sky render\",");
+ json.AppendLine(" \"terrain_module_sha256\": " + JsonString(ExpectedSha256[TerrainName]) + ",");
+ json.AppendLine(" \"raw_clock\": " + sky.RawClock + ",");
+ json.AppendLine(" \"origin_raw_clock\": " + sky.Origin + ",");
+ json.AppendLine(" \"frame_generation\": " + sky.Generation + ",");
+ json.AppendLine(" \"thread_id\": " + sky.ThreadId + ",");
+ json.AppendLine(" \"elapsed_milliseconds\": " + sky.ElapsedMilliseconds);
+ json.AppendLine(" },");
+ }
+
private static bool VerifyAtmospherePhaseBoundary(IntPtr process, uint terrainBase,
out byte firstByte, out string evidence)
{
@@ -1236,6 +1388,7 @@ internal static partial class NativeFrameCapture
return CanArmPresentForFrame(frame, threadId)
&& (!frame.SelectedCameraRequested || (frame.SelectedCamera != null
&& frame.SelectedCamera.Applied && frame.SelectedCamera.ProjectionMatchesInput
+ && frame.SelectedCamera.PrecedingFramesRemaining == 0
&& frame.SelectedCamera.CandidateProjectionVerified
&& frame.SelectedCamera.Restored && frame.SelectedCamera.RestoreVerified
&& frame.SelectedCamera.MatrixIntactAtReturn
@@ -1276,6 +1429,7 @@ internal static partial class NativeFrameCapture
&& frame.CameraThreadId == threadId && frame.ProjectionThreadId == threadId
&& (!frame.SelectedCameraRequested || (frame.SelectedCamera != null
&& frame.SelectedCamera.Applied && frame.SelectedCamera.ProjectionMatchesInput
+ && frame.SelectedCamera.PrecedingFramesRemaining == 0
&& frame.SelectedCamera.CandidateProjectionVerified
&& !frame.SelectedCamera.PixelAttributionInvalidated
&& SelectedMatrixMatchesProjection(frame)));
@@ -1843,11 +1997,14 @@ internal static partial class NativeFrameCapture
json.AppendLine(" \"far_plane\": " + projection.Far.ToString("R", CultureInfo.InvariantCulture) + ",");
json.AppendLine(" \"field_of_view_radians\": " + projection.Fov.ToString("R", CultureInfo.InvariantCulture) + ",");
json.AppendLine(" \"projection_mode_byte\": " + projection.Mode.ToString(CultureInfo.InvariantCulture) + ",");
+ AppendGeometryProjectionJson(json, frame);
+ AppendSkyUvJson(json, frame);
if (frame.SelectedCamera == null) json.AppendLine(" \"selected_camera\": null,");
else
{
SelectedCameraState selected = frame.SelectedCamera;
json.AppendLine(" \"selected_camera\": {");
+ json.AppendLine(" \"preceding_selected_world_frames\": " + selected.PrecedingFramesCompleted + ",");
json.AppendLine(" \"input_json\": " + JsonString(selected.Input.Path) + ",");
json.AppendLine(" \"input_kind\": " + JsonString(selected.Input.Synthetic
? "synthetic-camera-plan" : "native-capture") + ",");
@@ -1949,6 +2106,9 @@ internal static partial class NativeFrameCapture
{
if (IntPtr.Size != 4) throw new InvalidOperationException("This helper must be compiled and run as x86.");
SelfCheckCameraInput();
+ SelfCheckSelectedCameraWarmup();
+ SelfCheckGeometryProjection();
+ SelfCheckSkyUv();
SelfCheckNativeFogPairing();
SelfCheckRenderInvocationGates();
SelfCheckDebugEventHandleOwnership();
@@ -2088,6 +2248,10 @@ internal static partial class NativeFrameCapture
bool wrongCameraThreadRejected = !IsMatchingPerspectiveFrame(matched, 12);
bool emptyCameraFrameRejected = !IsMatchingPerspectiveFrame(new FrameSnapshot(), 11);
bool selectedFrameValid = IsMatchingPerspectiveFrame(selectedMatched, 11);
+ selectedMatched.SelectedCamera.PrecedingFramesRemaining = 1;
+ bool selectedWarmupRejected = !CanArmPresentForFrame(selectedMatched, 11)
+ && !IsMatchingPerspectiveFrame(selectedMatched, 11);
+ selectedMatched.SelectedCamera.PrecedingFramesRemaining = 0;
bool selectedUnrestoredRejected = !IsMatchingPerspectiveFrame(selectedUnrestored, 11);
bool selectedUnrestoredCaptureEligible = CanArmPresentForFrame(selectedUnrestored, 11);
bool selectedIntervenedCaptureRejected = !CanArmPresentForFrame(selectedIntervened, 11);
@@ -2189,7 +2353,7 @@ internal static partial class NativeFrameCapture
|| !atmosphereFrameValid
|| !atmosphereClockMismatchRejected
|| !cameraFrameValid || !wrongCameraThreadRejected || !emptyCameraFrameRejected
- || !selectedFrameValid || !selectedUnrestoredRejected || !selectedUnrestoredCaptureEligible
+ || !selectedFrameValid || !selectedWarmupRejected || !selectedUnrestoredRejected || !selectedUnrestoredCaptureEligible
|| !selectedIntervenedCaptureRejected || !selectedIntervenedRejected
|| !selectedChangedMatrixRejected
|| !selectedBoundaryReadAllowed || !boundaryReadRejectsUnselected
@@ -2255,6 +2419,60 @@ internal static partial class NativeFrameCapture
return SelfCheckReadback();
}
+ private static void SelfCheckSkyUv()
+ {
+ FrameSnapshot frame = new FrameSnapshot { CameraGeneration = 7, ProjectionGeneration = 7,
+ CameraThreadId = 11, ProjectionThreadId = 11, WorldGameTimeWordReadable = true,
+ CameraWorldGameTimeWordReadable = true, CameraWorldGameTimeWord = 265584022,
+ WorldGameTimeWord = 265584022,
+ SkyUv = new SkyUvReadback { Generation = 7, ThreadId = 11, RawClock = 265584022,
+ Origin = 509280060, ElapsedMilliseconds = unchecked(265584022U - 509280060U) } };
+ StringBuilder json = new StringBuilder();
+ AppendSkyUvJson(json, frame);
+ if (!SkyUvMatchesFrame(frame) || json.ToString().IndexOf("4051271258", StringComparison.Ordinal) < 0)
+ throw new InvalidOperationException("Verified sky UV wrapping clock was rejected.");
+ frame.SkyUv.ThreadId++;
+ if (SkyUvMatchesFrame(frame)) throw new InvalidOperationException("Sky UV thread mismatch accepted.");
+ frame.SkyUv.ThreadId--;
+ frame.SkyUv.Generation++;
+ if (SkyUvMatchesFrame(frame)) throw new InvalidOperationException("Sky UV generation mismatch accepted.");
+ frame.SkyUv.Generation--;
+ frame.SkyUv.RawClock++;
+ json.Length = 0;
+ AppendSkyUvJson(json, frame);
+ if (SkyUvMatchesFrame(frame) || json.ToString().IndexOf("null", StringComparison.Ordinal) < 0)
+ throw new InvalidOperationException("Sky UV clock mismatch accepted.");
+ Console.WriteLine("self-check: sky UV clock requires matching thread/generation/world clock and preserves u32 wrap OK");
+ }
+
+ private static void SelfCheckGeometryProjection()
+ {
+ FrameSnapshot frame = new FrameSnapshot {
+ CameraGeneration = 7, ProjectionGeneration = 7, GeometryProjectionGeneration = 7,
+ CameraThreadId = 11, ProjectionThreadId = 11, GeometryProjectionThreadId = 11,
+ Projection = new ProjectionReadback { Renderer = 100, Viewport = new int[] { 0, 0, 1280, 1024 },
+ Near = 5, Far = 700, Fov = 1.3f, Mode = 1, Verified = true },
+ GeometryProjection = new ProjectionReadback { Renderer = 100, Viewport = new int[] { 0, 0, 1280, 1024 },
+ Near = 0.5f, Far = 700, Fov = 1.3f, Mode = 1, Verified = true }
+ };
+ StringBuilder json = new StringBuilder("{\n");
+ AppendGeometryProjectionJson(json, frame);
+ json.Append("\"end\":true}");
+ Dictionary<string, object> parsed = new System.Web.Script.Serialization.JavaScriptSerializer()
+ .Deserialize<Dictionary<string, object>>(json.ToString());
+ Dictionary<string, object> projection = (Dictionary<string, object>)parsed["geometry_projection"];
+ if (Convert.ToSingle(projection["near_plane"]) != 0.5f)
+ throw new InvalidOperationException("Geometry projection must preserve draw near, not restored camera near.");
+ frame.GeometryProjectionThreadId++;
+ if (GeometryProjectionMatchesFrame(frame)) throw new InvalidOperationException("Geometry projection thread mismatch accepted.");
+ frame.GeometryProjectionThreadId--;
+ frame.GeometryProjectionGeneration--;
+ if (GeometryProjectionMatchesFrame(frame)) throw new InvalidOperationException("Geometry projection generation mismatch accepted.");
+ frame.GeometryProjectionGeneration++;
+ frame.GeometryProjection.Viewport[2]--;
+ if (GeometryProjectionMatchesFrame(frame)) throw new InvalidOperationException("Geometry projection viewport mismatch accepted.");
+ }
+
private static void SelfCheckNativeFogPairing()
{
if (FogConsumerRva != 0x2F230 || FogConsumerVtableRva != 0x6527C
@@ -2894,6 +3112,8 @@ internal static partial class NativeFrameCapture
string terrainModuleSha256 = null;
BreakpointInfo cameraBreakpoint = null;
BreakpointInfo projectionBreakpoint = null;
+ BreakpointInfo geometryProjectionBreakpoint = null;
+ BreakpointInfo skyUvBreakpoint = null;
BreakpointInfo returnBreakpoint = null;
BreakpointInfo atmosphereBreakpoint = null;
BreakpointInfo beginSceneBreakpoint = null;
@@ -2919,7 +3139,8 @@ internal static partial class NativeFrameCapture
bool projectionSelectorMode = boundaryTrace || boundaryCapture;
frame.SelectedCameraRequested = selectedCameraInput != null;
SelectedCameraState selectedCamera = selectedCameraInput == null ? null
- : new SelectedCameraState { Input = selectedCameraInput };
+ : new SelectedCameraState { Input = selectedCameraInput,
+ PrecedingFramesRemaining = boundaryCapture ? 0 : 2 };
try
{
if (identityProcess == null || identityProcess.HasExited
@@ -3076,6 +3297,21 @@ internal static partial class NativeFrameCapture
throw new InvalidOperationException("Loaded Terrain hash mismatch: " + hash);
terrainBase = imageBase;
terrainModuleSha256 = hash;
+ byte[] geometryPrefix = new byte[] { 0x81, 0xEC, 0xBC, 0, 0, 0, 0x53, 0x55, 0x8B, 0xE9 };
+ byte[] geometryCode = new byte[geometryPrefix.Length];
+ if (!ReadExact(process, unchecked(terrainBase + GeometryProjectionRva), geometryCode)
+ || !ByteArraysEqual(geometryCode, geometryPrefix))
+ throw new InvalidOperationException("Terrain geometry projection boundary signature failed.");
+ geometryProjectionBreakpoint = ArmBreakpoint(process, "Terrain.geometry-projection",
+ unchecked(terrainBase + GeometryProjectionRva), geometryPrefix[0]);
+ byte[] skyPrefix = new byte[] { 0x6A, 0xFF, 0x68, 0, 0, 0, 0, 0x64, 0xA1, 0, 0, 0, 0 };
+ Array.Copy(BitConverter.GetBytes(unchecked(terrainBase + 0x637E6)), 0, skyPrefix, 3, 4);
+ byte[] skyCode = new byte[skyPrefix.Length];
+ if (!ReadExact(process, unchecked(terrainBase + SkyRenderRva), skyCode)
+ || !ByteArraysEqual(skyCode, skyPrefix))
+ throw new InvalidOperationException("Terrain sky render boundary signature failed.");
+ skyUvBreakpoint = ArmBreakpoint(process, "Terrain.sky-uv-clock",
+ unchecked(terrainBase + SkyRenderRva), skyPrefix[0]);
byte atmosphereByte;
string atmosphereEvidence;
if (!VerifyAtmospherePhaseBoundary(process, terrainBase, out atmosphereByte, out atmosphereEvidence))
@@ -3330,6 +3566,12 @@ internal static partial class NativeFrameCapture
Log("CAMERA_SETTER_REGION_FREED phase=Restore");
else Log("CAMERA_SETTER_REGION_FREE_FAILED phase=Restore error=" + Marshal.GetLastWin32Error());
cameraSetter = null;
+ if (!stopAfterAttempt && selectedCamera.PrecedingFramesRemaining > 0)
+ {
+ selectedCamera = NextSelectedWarmupFrame(selectedCamera);
+ Log("SELECTED_CAMERA_WARMUP_COMPLETE completed=" + selectedCamera.PrecedingFramesCompleted
+ + " remaining=" + selectedCamera.PrecedingFramesRemaining);
+ }
}
}
}
@@ -3535,6 +3777,12 @@ internal static partial class NativeFrameCapture
hitBreakpoint = cameraBreakpoint;
else if (projectionBreakpoint != null && projectionBreakpoint.Armed && exceptionAddress == projectionBreakpoint.Address)
hitBreakpoint = projectionBreakpoint;
+ else if (geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed
+ && exceptionAddress == geometryProjectionBreakpoint.Address)
+ hitBreakpoint = geometryProjectionBreakpoint;
+ else if (skyUvBreakpoint != null && skyUvBreakpoint.Armed
+ && exceptionAddress == skyUvBreakpoint.Address)
+ hitBreakpoint = skyUvBreakpoint;
else if (returnBreakpoint != null && returnBreakpoint.Armed && exceptionAddress == returnBreakpoint.Address)
hitBreakpoint = returnBreakpoint;
else if (atmosphereBreakpoint != null && atmosphereBreakpoint.Armed && exceptionAddress == atmosphereBreakpoint.Address)
@@ -3608,6 +3856,16 @@ internal static partial class NativeFrameCapture
&& !RestoreBreakpointWhileStopped(process, fogConsumerBreakpoint))
throw new InvalidOperationException("Could not disarm the previous generation's native fog boundary.");
frame.CameraGeneration++;
+ frame.GeometryProjection = null;
+ frame.SkyUv = null;
+ if (skyUvBreakpoint != null && !skyUvBreakpoint.Armed)
+ skyUvBreakpoint = ArmBreakpoint(process, "Terrain.sky-uv-clock",
+ skyUvBreakpoint.Address, skyUvBreakpoint.OriginalByte);
+ frame.GeometryProjectionGeneration = 0;
+ frame.GeometryProjectionThreadId = 0;
+ if (geometryProjectionBreakpoint != null && !geometryProjectionBreakpoint.Armed)
+ geometryProjectionBreakpoint = ArmBreakpoint(process, "Terrain.geometry-projection",
+ geometryProjectionBreakpoint.Address, geometryProjectionBreakpoint.OriginalByte);
ResetNativeFogForGeneration(frame);
frame.CameraThreadId = eventTid;
frame.CameraWorldGameTimeWordReadable = false;
@@ -3696,6 +3954,31 @@ internal static partial class NativeFrameCapture
frame, eventTid, ref fogConsumerBreakpoint);
}
}
+ else if (hitBreakpoint == skyUvBreakpoint)
+ {
+ if (frame.SkyUv == null && world3dBase != 0)
+ frame.SkyUv = CaptureSkyUv(process, terrainBase, world3dBase,
+ stoppedContext, frame, eventTid);
+ }
+ else if (hitBreakpoint == geometryProjectionBreakpoint)
+ {
+ uint esp = BitConverter.ToUInt32(stoppedContext, ContextEspOffset);
+ if (frame.Camera != null && frame.CameraThreadId == eventTid && ngiBase != 0
+ && ReadU32Exact(process, esp + 8) == frame.Camera.Camera + 0x60
+ && ReadU32Exact(process, esp + 4) == ReadU32Exact(process, ngiBase + NgiRendererGlobalRva))
+ {
+ ProjectionReadback p = ReadProjection(process, ngiBase);
+ if (p.Verified && p.Mode != 0)
+ {
+ frame.GeometryProjection = p;
+ frame.GeometryProjectionGeneration = frame.CameraGeneration;
+ frame.GeometryProjectionThreadId = eventTid;
+ Log("GEOMETRY_PROJECTION generation=" + frame.CameraGeneration + " tid=" + eventTid
+ + " near=" + p.Near.ToString("R", CultureInfo.InvariantCulture)
+ + " far=" + p.Far.ToString("R", CultureInfo.InvariantCulture));
+ }
+ }
+ }
else if (hitBreakpoint == projectionBreakpoint)
{
string candidateProjectionEvidence = "candidate preconditions not met";
@@ -3870,7 +4153,8 @@ internal static partial class NativeFrameCapture
&& targetProjectionMatchesInput)
+ " rawWorldTime=0x" + frame.WorldGameTimeWord.ToString("X8"));
if (cameraWordsCurrent && frame.Projection.Verified && frame.Projection.Mode != 0
- && targetProjectionMatchesInput && ngiBase != 0 && presentBoundaryVerified)
+ && targetProjectionMatchesInput && ngiBase != 0 && presentBoundaryVerified
+ && (selectedCamera == null || selectedCamera.PrecedingFramesRemaining == 0))
EnsurePresentBreakpoint(process, ngiBase, frame, eventTid,
presentOriginalByte, ref presentBreakpoint);
}
@@ -3919,7 +4203,7 @@ internal static partial class NativeFrameCapture
{
remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase,
returnBreakpoint, frame,
- new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint,
+ new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint,
returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
fogConsumerBreakpoint,
endSceneBreakpoint, presentBreakpoint,
@@ -3975,7 +4259,7 @@ internal static partial class NativeFrameCapture
}
cameraSetter = StartCameraSetter(process, eventTid, terrainBase,
selectedCamera.CameraPointer, selectedCamera.OriginalMatrixBytes,
- stoppedContext, returnBreakpoint, false, CameraSetterPhase.Restore,
+ stoppedContext, returnBreakpoint, selectedCamera.PrecedingFramesRemaining > 0, CameraSetterPhase.Restore,
selectedCamera.EntryEsp, selectedCamera.ReturnAddress);
Log("SELECTED_CAMERA_RESTORE_STARTED generation=" + selectedCamera.Generation
+ " camera=0x" + selectedCamera.CameraPointer.ToString("X8")
@@ -4067,7 +4351,7 @@ internal static partial class NativeFrameCapture
{
remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase,
endSceneBreakpoint, frame,
- new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint,
+ new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint,
returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
endSceneBreakpoint,
fogConsumerBreakpoint, presentBreakpoint,
@@ -4115,7 +4399,7 @@ internal static partial class NativeFrameCapture
{
remote = StartRemoteBoundaryReadback(process, eventTid, ngiBase,
endSceneBreakpoint, frame, outputPath,
- new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint,
+ new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint,
returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
endSceneBreakpoint,
fogConsumerBreakpoint, presentBreakpoint,
@@ -4333,6 +4617,10 @@ internal static partial class NativeFrameCapture
if (hitBreakpoint == fogConsumerBreakpoint)
rearmAfterStep = !frame.NativeFogSamplingStopped
&& !frame.NativeFogSampleLimitExceeded;
+ if (hitBreakpoint == geometryProjectionBreakpoint)
+ rearmAfterStep = frame.GeometryProjection == null;
+ if (hitBreakpoint == skyUvBreakpoint)
+ rearmAfterStep = frame.SkyUv == null;
BeginSingleStep(process, eventTid, hitBreakpoint, stoppedContext,
out pendingStep, rearmAfterStep);
Log("SINGLE_STEP_STARTED boundary=" + hitBreakpoint.Name + " tid=" + eventTid);
@@ -4405,7 +4693,7 @@ internal static partial class NativeFrameCapture
captureFailure = captureFailure ?? "No matching native camera/projection/present frame completed before the bounded timeout.";
Log("NO_FRAME_CAPTURE bounded timeout at stopped debug event");
if (!RestoreArmedBreakpointsWhileStopped(process,
- cameraBreakpoint, projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint,
+ cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint,
beginSceneBreakpoint,
endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
presentCompletionBreakpoint, missionBreakpoint))
@@ -4425,6 +4713,8 @@ internal static partial class NativeFrameCapture
&& !fatal && !processExited
&& (cameraBreakpoint != null && cameraBreakpoint.Armed
|| projectionBreakpoint != null && projectionBreakpoint.Armed
+ || geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed
+ || skyUvBreakpoint != null && skyUvBreakpoint.Armed
|| returnBreakpoint != null && returnBreakpoint.Armed
|| atmosphereBreakpoint != null && atmosphereBreakpoint.Armed
|| beginSceneBreakpoint != null && beginSceneBreakpoint.Armed
@@ -4435,7 +4725,7 @@ internal static partial class NativeFrameCapture
|| missionBreakpoint != null && missionBreakpoint.Armed))
{
if (!RestoreArmedBreakpointsWhileStopped(process,
- cameraBreakpoint, projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint,
+ cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint,
beginSceneBreakpoint,
endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
presentCompletionBreakpoint, missionBreakpoint))
@@ -4484,6 +4774,8 @@ internal static partial class NativeFrameCapture
|| cameraMutationUnrestored || eventOutstanding
|| cameraBreakpoint != null && cameraBreakpoint.Armed
|| projectionBreakpoint != null && projectionBreakpoint.Armed
+ || geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed
+ || skyUvBreakpoint != null && skyUvBreakpoint.Armed
|| returnBreakpoint != null && returnBreakpoint.Armed
|| atmosphereBreakpoint != null && atmosphereBreakpoint.Armed
|| beginSceneBreakpoint != null && beginSceneBreakpoint.Armed
@@ -4513,7 +4805,7 @@ internal static partial class NativeFrameCapture
if (DrainFrameTerminationEvents(process, eventBuffer, processId,
ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode,
ref outstandingContinueStatus, ref outstandingFileHandleClosed,
- new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, returnBreakpoint,
+ new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint,
atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint,
presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint },
out drainEvidence))
@@ -4582,7 +4874,7 @@ internal static partial class NativeFrameCapture
if (DrainFrameTerminationEvents(process, eventBuffer, processId,
ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode,
ref outstandingContinueStatus, ref outstandingFileHandleClosed,
- new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, returnBreakpoint,
+ new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint,
atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint,
presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint },
out drainEvidence))
@@ -4620,7 +4912,7 @@ internal static partial class NativeFrameCapture
else Log("PROCESS_STATE_AFTER_DETACH unreadable error=" + Marshal.GetLastWin32Error());
}
breakpointsRestored = processExited || AreBreakpointsRestored(cameraBreakpoint,
- projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
+ projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
presentCompletionBreakpoint, missionBreakpoint);
Log("FINAL_BREAKPOINT_STATE restored=" + breakpointsRestored + " processExited=" + processExited);
@@ -4752,7 +5044,7 @@ internal static partial class NativeFrameCapture
}
private static bool RestoreArmedBreakpointsWhileStopped(IntPtr process,
- BreakpointInfo cameraBreakpoint, BreakpointInfo projectionBreakpoint, BreakpointInfo returnBreakpoint,
+ BreakpointInfo cameraBreakpoint, BreakpointInfo projectionBreakpoint, BreakpointInfo geometryProjectionBreakpoint, BreakpointInfo skyUvBreakpoint, BreakpointInfo returnBreakpoint,
BreakpointInfo atmosphereBreakpoint, BreakpointInfo beginSceneBreakpoint, BreakpointInfo endSceneBreakpoint,
BreakpointInfo fogConsumerBreakpoint,
BreakpointInfo presentBreakpoint, BreakpointInfo presentCompletionBreakpoint,
@@ -4760,6 +5052,8 @@ internal static partial class NativeFrameCapture
{
return RestoreBreakpointWhileStopped(process, cameraBreakpoint)
&& RestoreBreakpointWhileStopped(process, projectionBreakpoint)
+ && RestoreBreakpointWhileStopped(process, geometryProjectionBreakpoint)
+ && RestoreBreakpointWhileStopped(process, skyUvBreakpoint)
&& RestoreBreakpointWhileStopped(process, returnBreakpoint)
&& RestoreBreakpointWhileStopped(process, atmosphereBreakpoint)
&& RestoreBreakpointWhileStopped(process, beginSceneBreakpoint)
@@ -4771,7 +5065,7 @@ internal static partial class NativeFrameCapture
}
private static bool AreBreakpointsRestored(BreakpointInfo cameraBreakpoint,
- BreakpointInfo projectionBreakpoint, BreakpointInfo returnBreakpoint,
+ BreakpointInfo projectionBreakpoint, BreakpointInfo geometryProjectionBreakpoint, BreakpointInfo skyUvBreakpoint, BreakpointInfo returnBreakpoint,
BreakpointInfo atmosphereBreakpoint, BreakpointInfo beginSceneBreakpoint, BreakpointInfo endSceneBreakpoint,
BreakpointInfo fogConsumerBreakpoint,
BreakpointInfo presentBreakpoint, BreakpointInfo presentCompletionBreakpoint,
@@ -4779,6 +5073,8 @@ internal static partial class NativeFrameCapture
{
return (cameraBreakpoint == null || !cameraBreakpoint.Armed)
&& (projectionBreakpoint == null || !projectionBreakpoint.Armed)
+ && (geometryProjectionBreakpoint == null || !geometryProjectionBreakpoint.Armed)
+ && (skyUvBreakpoint == null || !skyUvBreakpoint.Armed)
&& (returnBreakpoint == null || !returnBreakpoint.Armed)
&& (atmosphereBreakpoint == null || !atmosphereBreakpoint.Armed)
&& (beginSceneBreakpoint == null || !beginSceneBreakpoint.Armed)
diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md
index 428f8c2..d8f621c 100644
--- a/tools/native-frame-capture/README.md
+++ b/tools/native-frame-capture/README.md
@@ -2,6 +2,10 @@
Capture modes attach only to the isolated GOG-compatible scratch copy of `Parkan - Iron Strategy`. Capture writes a PNG and a `fparkan-legacy-camera-v1` JSON file; boundary-trace mode writes a log and captures no pixels. `selector0_words` remains the local camera matrix at `camera+0x20`; the capture also records `effective_world_matrix_words` from `camera+0x60` with `effective_world_matrix_space: "world"` and a `parented_camera` marker. Both local and world matrices are sampled at the World3D render entry and reread at the projection-building boundary; only a same-thread, byte-compared snapshot paired with perspective values can produce usable renderer input. A passive `iron3d.dll` callsite probe records a path only when the active call arguments and path string are verified. It also samples `Terrain+0x421DC` after verifying the GOG module hash and the `EBP - [ESI+0x144]`, remainder by `[ESI+0x150]` instruction bytes. `atmosphere_seconds` is emitted only if the sampled `EDX` phase matches that arithmetic, the raw clock matches `World3D+0x32A38`, and sample, camera, and projection share the same render thread and generation. Otherwise it is `null`. This phase does not establish a general simulation-time, weather, or RNG value.
+`geometry_projection` records the renderer's perspective values at the first indexed geometry draw (`Terrain+0x1D110`) using that world's camera matrix and renderer. It is emitted only when thread, generation, renderer, viewport, far plane and FOV match the later World3D boundary. The near plane may differ: the observed world draw uses `0.5`, while the restored camera boundary contains `5`. FParkan prefers this verified object when replaying the capture. The top-level projection fields remain the camera-boundary tuple used for selected-camera preflight. Older captures without this object still load, with `render_coverage captured_projection_unverified=1`; nearby surfaces in those replays can be clipped incorrectly. Capture a fresh frame to verify them.
+
+`sky_uv_clock` records the independent scrolling-texture clock at sky render entry (`Terrain+0x48110`). It requires a verified module/prologue, the selected camera, matching render thread/generation/world clock, and zenith UV values matching all three native scroll formulas. It preserves wrapping `u32` subtraction from the sky object's origin clock. FParkan holds this phase during exact capture replay; ordinary application frames animate the cloud, star and nebula UVs with periods of 50, 100 and 300 seconds. Older captures without this object still load and report `render_coverage captured_sky_phase_unverified=1`; their sky texture phase is not a native reference.
+
The JSON keeps the `fparkan-legacy-camera-v1` schema and adds `native_fog_status`, diagnostic counts, and `native_fog`. The latter is an optional passive CPU-side snapshot of the effective sink flags/RGB/range plus the paired calls to the `Terrain+0x2F230` consumer. The flags byte is raw and not a decoded fog mode. Each draw record preserves the distance argument, raw bypass dword and effective low byte, source color, output pointers, and return address. Geometry at `EBP` is included only for the verified `Terrain+0x18A6A` caller with the expected geometry vtable and slot+0x14 target; other callers have `distance_geometry: null`. The fog object is `null` unless the samples match the same render thread, camera, generation, and projection matrix and agree on sink settings. `native_fog_status` is `paired`, `conflict`, `unpaired`, or `incomplete`; `native_fog_status_reason` explains an unpaired, conflicting, or incomplete result. The counts include processed, overflow, paired, rejected, other-thread, and matrix-mismatch calls. At most 512 calls are processed per camera generation; reaching the limit stops sampling and reports an incomplete result. These fields are additive: camera-input validation continues to accept older JSON without them. This probe observes CPU consumer state; it does not set fog or change GPU state.
The capture helper expects an isolated copy at `target\shadow-probe\Parkan - Iron Strategy`; the original GOG installation stays untouched. For the default local install path, create that scratch copy from the repository root with:
@@ -74,6 +78,8 @@ if ($LASTEXITCODE -ne 0) { throw 'Selected-camera capture failed; inspect its lo
This verifies repeatable camera handling, not deterministic whole-scene pixels. The helper has no setter for FOV, near/far planes, or fog, and does not freeze simulation state; `atmosphere_seconds` remains an observed phase sample rather than a restored simulation clock. If the callsite probe cannot verify a mission path, `mission_path` remains `null` and `mission_identity` remains `unknown`.
+For `--capture --camera-input`, the requested pose is rendered on three consecutive world frames. The first two are discarded so portal visibility and other render caches can settle. The original local matrix is restored and checked after every frame; the third frame alone supplies the pixels, projection and atmosphere sample. `selected_camera.preceding_selected_world_frames` records the two preceding verified frames. A failed matrix or restoration check stops the sequence. Passive and EndScene-boundary capture keep their single-frame behavior.
+
For a second capture, reuse the still-running PID only after the first run detached successfully, and choose a new output basename. Reuse the same validated camera input if comparing the same pose. The helper rejects any existing JSON, PNG, or log with the chosen basename and never overwrites those files. After a successful one-shot capture it detaches and leaves the scratch game running. It explicitly closes only image-file handles from `CREATE_PROCESS` and `LOAD_DLL`; the system owns the process/thread handles supplied by `CREATE_PROCESS` and `CREATE_THREAD` until the corresponding exit event is continued. If guarded recovery must terminate the scratch process, the helper drains its outstanding and queued debug events through `EXIT_PROCESS` within five seconds, then verifies the process handle before detaching. It withholds output when process exit cannot be confirmed.
To compare a viewer frame with a native capture, use the same phase JSON for the camera and the PNG conversion. When `atmosphere_seconds` is present, the viewer uses it unless `--atmosphere-seconds` is given, and holds the atmosphere schedule at that sample for the fixed-camera readback. Choose fresh `viewer.bin` and `viewer.png` paths because the viewer writes the raw file and conversion refuses to overwrite an existing PNG.
@@ -110,3 +116,42 @@ or `44`/`50` for BGRA. Pass the viewer log's `readback_format` value; `37` is
the verified value for the run above, not a universal surface format. The
converter uses the installed `System.Drawing` PNG encoder and adds no package
dependency.
+
+## Сравнение областей кадра
+
+`compare-frames.ps1` собирает пару «оригинал / FParkan» без масштабирования и
+считает средний RGB каждой указанной области. Координаты областей относятся к
+viewport, то есть к PNG просмотрщика. Оригинальный PNG сначала обрезается по
+`viewport` из JSON камеры. Имена выбираются для конкретной сцены: например,
+`sky`, `terrain_near`, `terrain_far`, `facade`, `opening`; невидимые в этом
+ракурсе области добавлять не нужно. Панели HUD и движущиеся объекты следует
+оставлять вне измеряемых прямоугольников.
+
+Пример `regions.json` для интерьера 1280 × 1024:
+
+```json
+[
+ {"name":"ceiling_near","rect":[250,50,550,170]},
+ {"name":"wall","rect":[120,400,200,150]},
+ {"name":"opening","rect":[650,550,220,100]}
+]
+```
+
+```powershell
+& '.\tools\native-frame-capture\compare-frames.ps1' -SelfTest
+& '.\tools\native-frame-capture\compare-frames.ps1' `
+ -NativePng 'target\captures\r1\constructor\interior.png' `
+ -ViewerPng 'target\captures\r1\constructor\interior-after.png' `
+ -CameraJson 'target\captures\r1\constructor\interior.json' `
+ -RegionsJson 'target\captures\r1\constructor\interior-regions.json' `
+ -OutputPrefix 'target\captures\r1\constructor\interior-comparison'
+```
+
+Результат — PNG с парой и отмеченными областями, а также JSON со средними
+значениями каналов в диапазоне 0–255. Это значения сохранённого изображения,
+без перевода в линейное цветовое пространство. Скрипт отклоняет несовпадающие
+размеры, области за границей кадра и существующие выходные файлы. Совпадение
+средних цветов не доказывает совпадение геометрии, поэтому пару нужно также
+просмотреть. Камера и фаза должны происходить из одного нативного захвата.
+Для интерьера нужно проверить наличие комнат в PNG: выбранная камера проходит
+два предварительных мировых кадра, а при пассивном захвате игрок входит в здание.
diff --git a/tools/native-frame-capture/compare-frames.ps1 b/tools/native-frame-capture/compare-frames.ps1
new file mode 100644
index 0000000..2a83bf0
--- /dev/null
+++ b/tools/native-frame-capture/compare-frames.ps1
@@ -0,0 +1,106 @@
+[CmdletBinding(DefaultParameterSetName = 'Compare')]
+param(
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$NativePng,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$ViewerPng,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$CameraJson,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$RegionsJson,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$OutputPrefix,
+ [Parameter(Mandatory = $true, ParameterSetName = 'SelfTest')][switch]$SelfTest
+)
+
+$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.Drawing
+
+function Get-MeanRgb {
+ param([System.Drawing.Bitmap]$Bitmap, [System.Drawing.Rectangle]$Region)
+ if ($Region.X -lt 0 -or $Region.Y -lt 0 -or $Region.Width -le 0 -or $Region.Height -le 0 -or
+ $Region.Right -gt $Bitmap.Width -or $Region.Bottom -gt $Bitmap.Height) {
+ throw 'Region must be a nonempty rectangle within the image.'
+ }
+ $sum = [long[]]@(0, 0, 0)
+ for ($y = $Region.Top; $y -lt $Region.Bottom; $y++) {
+ for ($x = $Region.Left; $x -lt $Region.Right; $x++) {
+ $pixel = $Bitmap.GetPixel($x, $y)
+ $sum[0] += $pixel.R
+ $sum[1] += $pixel.G
+ $sum[2] += $pixel.B
+ }
+ }
+ $count = [long]$Region.Width * $Region.Height
+ return ,@($sum | ForEach-Object { [Math]::Round($_ / $count, 4) })
+}
+
+if ($SelfTest) {
+ $test = [System.Drawing.Bitmap]::new(2, 1)
+ try {
+ $test.SetPixel(0, 0, [System.Drawing.Color]::FromArgb(10, 20, 30))
+ $test.SetPixel(1, 0, [System.Drawing.Color]::FromArgb(30, 40, 50))
+ $mean = Get-MeanRgb $test ([System.Drawing.Rectangle]::new(0, 0, 2, 1))
+ if (($mean -join ',') -ne '20,30,40') { throw 'Mean RGB self-test failed.' }
+ $rejected = $false
+ try { Get-MeanRgb $test ([System.Drawing.Rectangle]::new(1, 0, 2, 1)) | Out-Null }
+ catch { $rejected = $true }
+ if (-not $rejected) { throw 'Out-of-bounds region was accepted.' }
+ Write-Output 'self-test: exact region RGB means and bounds rejection OK'
+ } finally { $test.Dispose() }
+ return
+}
+
+$pngPath = [System.IO.Path]::GetFullPath($OutputPrefix + '.png')
+$jsonPath = [System.IO.Path]::GetFullPath($OutputPrefix + '.json')
+if ((Test-Path -LiteralPath $pngPath) -or (Test-Path -LiteralPath $jsonPath)) {
+ throw 'Comparison output already exists; choose a new prefix.'
+}
+$camera = Get-Content -LiteralPath $CameraJson -Raw | ConvertFrom-Json
+if ($camera.schema -ne 'fparkan-legacy-camera-v1' -or $camera.viewport.Count -ne 4) {
+ throw 'Expected a legacy camera JSON with a four-value viewport.'
+}
+$viewport = @($camera.viewport)
+foreach ($value in $viewport) {
+ if ($value -lt 0 -or $value -gt [int]::MaxValue -or [Math]::Truncate($value) -ne $value) {
+ throw 'Viewport coordinates must be nonnegative integers.'
+ }
+}
+$crop = [System.Drawing.Rectangle]::new($viewport[0], $viewport[1], $viewport[2] - $viewport[0], $viewport[3] - $viewport[1])
+$native = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $NativePng).Path)
+$viewer = $nativeCrop = $pair = $graphics = $null
+try {
+ $viewer = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $ViewerPng).Path)
+ if ($crop.Width -le 0 -or $crop.Height -le 0 -or $crop.Right -gt $native.Width -or $crop.Bottom -gt $native.Height -or
+ $viewer.Width -ne $crop.Width -or $viewer.Height -ne $crop.Height) {
+ throw 'Native crop and viewer dimensions must match without scaling.'
+ }
+ $nativeCrop = $native.Clone($crop, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
+ $pair = [System.Drawing.Bitmap]::new($viewer.Width * 2, $viewer.Height + 24)
+ $graphics = [System.Drawing.Graphics]::FromImage($pair)
+ $graphics.Clear([System.Drawing.Color]::Black)
+ $graphics.DrawImageUnscaled($nativeCrop, 0, 24)
+ $graphics.DrawImageUnscaled($viewer, $viewer.Width, 24)
+ $font = [System.Drawing.SystemFonts]::DefaultFont
+ $graphics.DrawString('Original', $font, [System.Drawing.Brushes]::White, 4, 4)
+ $graphics.DrawString('FParkan', $font, [System.Drawing.Brushes]::White, $viewer.Width + 4, 4)
+ $regions = @(Get-Content -LiteralPath $RegionsJson -Raw | ConvertFrom-Json)
+ if ($regions.Count -eq 0) { throw 'At least one named region is required.' }
+ $report = foreach ($region in $regions) {
+ if ([string]::IsNullOrWhiteSpace($region.name) -or $region.rect.Count -ne 4) { throw 'Each region needs name and rect [x,y,width,height].' }
+ foreach ($value in $region.rect) {
+ if ($value -lt 0 -or $value -gt [int]::MaxValue -or [Math]::Truncate($value) -ne $value) { throw 'Region coordinates must be nonnegative integers.' }
+ }
+ $rect = [System.Drawing.Rectangle]::new($region.rect[0], $region.rect[1], $region.rect[2], $region.rect[3])
+ $originalMean = Get-MeanRgb $nativeCrop $rect
+ $viewerMean = Get-MeanRgb $viewer $rect
+ foreach ($offset in @(0, $viewer.Width)) {
+ $graphics.DrawRectangle([System.Drawing.Pens]::Yellow, $rect.X + $offset, $rect.Y + 24, $rect.Width, $rect.Height)
+ $graphics.DrawString($region.name, $font, [System.Drawing.Brushes]::Yellow, $rect.X + $offset, $rect.Y + 24)
+ }
+ [pscustomobject]@{ name = $region.name; rect = $region.rect; original_rgb = $originalMean; fparkan_rgb = $viewerMean }
+ }
+ $pair.Save($pngPath, [System.Drawing.Imaging.ImageFormat]::Png)
+ [pscustomobject]@{ native = $NativePng; viewer = $ViewerPng; camera = $CameraJson; rgb_encoding = 'stored image bytes, 0..255'; regions = @($report) } |
+ ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding UTF8
+ Write-Output "Wrote $pngPath and $jsonPath"
+} finally {
+ foreach ($resource in @($graphics, $pair, $nativeCrop, $viewer, $native)) {
+ if ($null -ne $resource) { $resource.Dispose() }
+ }
+}